diff --git a/.changeset/css-transform-keyword-colors.md b/.changeset/css-transform-keyword-colors.md new file mode 100644 index 00000000..a4be608a --- /dev/null +++ b/.changeset/css-transform-keyword-colors.md @@ -0,0 +1,5 @@ +--- +'@plextv/react-lightning-plugin-css-transform': patch +--- + +Drop unresolvable CSS keyword colors (`inherit`, `currentColor`, `initial`, `unset`, `revert`) instead of throwing. They have no fixed value to resolve, so they reached the invalid-hex throw and took the whole screen down via the error boundary. Now the property is omitted. Also keep zero-valued transform values (`if (value != null)`), which the previous truthy check dropped. diff --git a/.changeset/flatten-exit-animation-materialize.md b/.changeset/flatten-exit-animation-materialize.md new file mode 100644 index 00000000..2afcb2d2 --- /dev/null +++ b/.changeset/flatten-exit-animation-materialize.md @@ -0,0 +1,5 @@ +--- +'@plextv/react-lightning': patch +--- + +Fix flattened elements leaking when they run a reanimated exit animation. A layout-only view flattens to a placeholder node, and a placeholder's `animate()` is a no-op that never emits `stopped`. Reanimated defers node removal until the exit animation finishes, so on a flattened wrapper the finish never fired, the deferred destroy never ran, and the subtree (and its real image descendants) stayed on the scene forever, stacking on every remount. Materialize the element when a deferred-removal handler is attached so the exit animation runs on a real node and completes. diff --git a/.changeset/flatten-layout-views.md b/.changeset/flatten-layout-views.md new file mode 100644 index 00000000..87a5a8f4 --- /dev/null +++ b/.changeset/flatten-layout-views.md @@ -0,0 +1,5 @@ +--- +'@plextv/react-lightning': minor +--- + +Add a `flattenLayoutViews` render option: layout-only Views (no background, border, clip, non-neutral alpha/transform, or transition) skip renderer node creation entirely. The element keeps a lightweight placeholder, descendants attach to the nearest materialized ancestor, and layout positions accumulate across the flattened chain (folded at the layout write funnels, unwound in `getRelativePosition`/`onLayout`). A flattened element materializes a real node on the first prop that needs one (sticky, so per-focus style toggles don't churn nodes). Inert RN-layer props (handlers, testID) don't prevent flattening; visual props at neutral values (color 0, alpha 1, scale 1) don't either. Off by default. diff --git a/.changeset/flatten-scroll-fold-through.md b/.changeset/flatten-scroll-fold-through.md new file mode 100644 index 00000000..08ec814f --- /dev/null +++ b/.changeset/flatten-scroll-fold-through.md @@ -0,0 +1,5 @@ +--- +'@plextv/react-lightning': patch +--- + +Fix flattened content not scrolling: a scroll handler (or any code) that writes a flattened element's position straight through `node.x`/`node.y`, bypassing `setProps`, now folds through to the hoisted children. The placeholder's `x`/`y` are accessors that notify the owning element, which re-pushes the offset to descendants. Without this, a direct write landed on the inert placeholder and the content only jumped to its final position on the next React commit (no animation). diff --git a/.changeset/flexbox-layout-benchmark.md b/.changeset/flexbox-layout-benchmark.md new file mode 100644 index 00000000..17828513 --- /dev/null +++ b/.changeset/flexbox-layout-benchmark.md @@ -0,0 +1,5 @@ +--- +'@plextv/react-lightning-plugin-flexbox': patch +--- + +Add a deterministic layout benchmark (synthetic home/details/grid/epg pages run through the real managers) with a baseline gate, so layout changes are checked instead of eyeballed. diff --git a/.changeset/flexbox-logical-position-insets.md b/.changeset/flexbox-logical-position-insets.md new file mode 100644 index 00000000..7ae2f754 --- /dev/null +++ b/.changeset/flexbox-logical-position-insets.md @@ -0,0 +1,5 @@ +--- +'@plextv/react-lightning-plugin-flexbox': patch +--- + +Logical `start`/`end` position insets now map to yoga's `EDGE_START`/`EDGE_END` (LTR), matching the existing logical margin/padding handling. Previously they were silently dropped, so an absolutely positioned box pinned with `end: 0` fell back to the left edge. diff --git a/.changeset/flexbox-missing-glyph-fallback.md b/.changeset/flexbox-missing-glyph-fallback.md new file mode 100644 index 00000000..bfff507b --- /dev/null +++ b/.changeset/flexbox-missing-glyph-fallback.md @@ -0,0 +1,5 @@ +--- +"@plextv/react-lightning-plugin-flexbox": patch +--- + +Measure missing glyphs at the ? fallback advance so text boxes match painted output diff --git a/.changeset/flexbox-reset-dropped-style-props.md b/.changeset/flexbox-reset-dropped-style-props.md new file mode 100644 index 00000000..d4d0472d --- /dev/null +++ b/.changeset/flexbox-reset-dropped-style-props.md @@ -0,0 +1,5 @@ +--- +'@plextv/react-lightning-plugin-flexbox': patch +--- + +Reset yoga props to their defaults when a style re-apply drops them diff --git a/.changeset/flexbox-sync-flush-settled.md b/.changeset/flexbox-sync-flush-settled.md new file mode 100644 index 00000000..2b97c24d --- /dev/null +++ b/.changeset/flexbox-sync-flush-settled.md @@ -0,0 +1,5 @@ +--- +'@plextv/react-lightning-plugin-flexbox': minor +--- + +Add a synchronous flushLayout() that lays out to a fixpoint, and a settled event that fires once layout converges. Deterministic replacement for the timer-based "has it settled yet" guesses in VirtualList. diff --git a/.changeset/focus-skip-empty-groups.md b/.changeset/focus-skip-empty-groups.md new file mode 100644 index 00000000..dde8334e --- /dev/null +++ b/.changeset/focus-skip-empty-groups.md @@ -0,0 +1,5 @@ +--- +'@plextv/react-lightning': patch +--- + +A FocusGroup with no focusable descendant is now skipped by spatial navigation and autoFocus instead of acting as a focus stop. Groups only delegate focus to their children, so a group wrapping non-interactive content (e.g. a list section header) shouldn't be a target; real leaves (Pressable, a `focusable` View) are unaffected. Effective focusability tracks `hasFocusableChildren` and propagates up the ancestor chain, so a group flips back the moment a focusable child mounts (or its last one is removed). diff --git a/.changeset/lightning-animated-transform-and-resting-styles.md b/.changeset/lightning-animated-transform-and-resting-styles.md new file mode 100644 index 00000000..ca28a9c0 --- /dev/null +++ b/.changeset/lightning-animated-transform-and-resting-styles.md @@ -0,0 +1,8 @@ +--- +'@plextv/react-lightning-plugin-flexbox': patch +'@plextv/react-lightning-plugin-reanimated': patch +--- + +fix(reanimated): apply animated transforms and replay resting styles to late-attached nodes + +Two gaps stopped a reanimated `transform` (e.g. a scroll-linked `translateY`) from reaching a laid-out node. The flexbox worker proxy filtered every non-flex style key before postMessage, so `transform` was dropped even though the worker-side Yoga already applies it as a top/left offset (and the serializer special-cases transform objects) — let it through. And `useAnimatedStyle` only pushed styles when a shared value changed, so a view that registers after the fact (recycled cell, re-created node) never got the current resting value; `AnimatedStyle` now exposes `applyToView`, which `createAnimatedComponent` calls on registration to replay the last-applied styles. Replay-only on purpose: computing a fresh value at attach time pushed states the normal flow never emitted and broke focus on some nodes. diff --git a/.changeset/lightning-aspect-ratio.md b/.changeset/lightning-aspect-ratio.md new file mode 100644 index 00000000..da0ac551 --- /dev/null +++ b/.changeset/lightning-aspect-ratio.md @@ -0,0 +1,7 @@ +--- +"@plextv/react-lightning-plugin-flexbox": patch +--- + +fix(flexbox): parse string `aspectRatio` values so ratio-sized nodes get a box + +React Native accepts `aspectRatio` as a number (`1.5`), a ratio string (`'3/2'`), or a numeric string (`'1.5'`), but the value was passed straight to Yoga's `setAspectRatio`, which only takes a number. String forms became `NaN` and the ratio was silently dropped — so a node sized only by `aspectRatio` plus one dimension (e.g. an image with `aspectRatio: '3/2'` and `height: '65%'` but no width) resolved to zero width and never painted. String ratios are now parsed to a number before being applied. diff --git a/.changeset/lightning-border-shader-clear.md b/.changeset/lightning-border-shader-clear.md new file mode 100644 index 00000000..63b8cc4c --- /dev/null +++ b/.changeset/lightning-border-shader-clear.md @@ -0,0 +1,7 @@ +--- +'@plextv/react-lightning': patch +--- + +fix(react-lightning): clear a removed border's shader on full restyles + +The keep-shader guard (added so reanimated's partial pushes don't square off a rounded node) inferred "partial" from "no shader-relevant prop present". A full restyle that dropped a border matched that too, so a removed focus-ring border kept painting. It now gates on the PARTIAL_STYLE marker: a reconciler snapshot recomputes (and clears) the shader, while reanimated and imperative single-key style sets keep it. Imperative `el.style.x =` pushes are marked PARTIAL_STYLE too. diff --git a/.changeset/lightning-find-node-handle.md b/.changeset/lightning-find-node-handle.md new file mode 100644 index 00000000..2b667418 --- /dev/null +++ b/.changeset/lightning-find-node-handle.md @@ -0,0 +1,5 @@ +--- +'@plextv/react-native-lightning': patch +--- + +Add a findNodeHandle export that returns the element ref instead of throwing (react-native-web's re-export throws unconditionally). Lightning focus APIs (setDestinations, focus hints) take element refs directly, so shared RN code that funnels refs through findNodeHandle now works unchanged. diff --git a/.changeset/lightning-flex-nonzero-sizing.md b/.changeset/lightning-flex-nonzero-sizing.md new file mode 100644 index 00000000..2e484f39 --- /dev/null +++ b/.changeset/lightning-flex-nonzero-sizing.md @@ -0,0 +1,8 @@ +--- +"@plextv/react-lightning": minor +"@plextv/react-lightning-plugin-flexbox": patch +--- + +fix(flexbox): withhold paint until first layout to avoid the async-flex origin flash + +Flex layout is computed asynchronously (in a worker), so a definite-sized node mounts and paints at its pre-layout origin (0,0) for a frame or two before the layout result moves it. A node now keeps its rendered alpha at 0 from mount until its first layout resolves, then restores the styled alpha (`withholdPaintUntilLayout` / `releaseWithheldPaint`). Zero-sized and already-invisible nodes are skipped, and subtrees detached from flex layout are released so they can never be stranded invisible. diff --git a/.changeset/lightning-flex-shrink-on-child-removal.md b/.changeset/lightning-flex-shrink-on-child-removal.md new file mode 100644 index 00000000..493cf451 --- /dev/null +++ b/.changeset/lightning-flex-shrink-on-child-removal.md @@ -0,0 +1,7 @@ +--- +'@plextv/react-lightning-plugin-flexbox': patch +--- + +fix(flexbox): detach removed nodes from the yoga parent so shrink-fit containers shrink + +`removeNode` freed the child's yoga node and spliced the ManagerNode children array, but never called `parent.node.removeChild(child.node)` on the yoga nodes themselves (unlike `detachChildNode`). The freed child stayed in the parent's yoga child list, so on the next layout the parent kept laying it out and a shrink-to-content container never shrank back. Visible as buttons that grow to fit a label on focus but stay expanded after blur once the label is removed. Now the child is detached from its yoga parent before it's freed. diff --git a/.changeset/lightning-focus-engine.md b/.changeset/lightning-focus-engine.md new file mode 100644 index 00000000..313260c9 --- /dev/null +++ b/.changeset/lightning-focus-engine.md @@ -0,0 +1,7 @@ +--- +"@plextv/react-lightning": minor +--- + +feat(focus): focus-when-ready, arrival-not-mount autoFocus, and destinations-on-arrival + +`FocusManager.focus()` no longer drops a request for an element that isn't registered or focusable yet — it queues it and resolves the moment the element becomes ready, so callers don't have to poll across frames. A later-mounting `autoFocus` child no longer steals live focus on registration (a new `focusCommitted` flag gates the upgrade), matching native `TVFocusGuideView`, which forwards focus on arrival rather than on mount. And `destinations` are now honoured on arrival (first visit without `focusRedirect`, every visit with it), so focus forwards to a declared destination then remembers the last-focused child. diff --git a/.changeset/lightning-image-border-paint.md b/.changeset/lightning-image-border-paint.md new file mode 100644 index 00000000..9a94f7e8 --- /dev/null +++ b/.changeset/lightning-image-border-paint.md @@ -0,0 +1,12 @@ +--- +"@plextv/react-lightning": minor +"@plextv/react-native-lightning": patch +--- + +fix(image+border): flatten array styles on Image and paint/clear border shaders on live nodes + +The RN `Image` component built its node style with an object spread (`{ ...style, w, h }`), so an RN style array (`style={[a, b]}`) became numeric-keyed garbage and its `width`/`height`/`borderRadius` were silently dropped (the array-flatten polyfill only ran when the style reached `setProps` still an array). `Image` now flattens with `flattenStyles` before spreading. + +Border shaders can now be toggled on an already-mounted node. `border` and `borderColor` were missing from the set of style props that force the shader-creating slow path, so toggling a plain border (e.g. a focus ring) fast-pathed straight onto the node and never created a `Border` shader. A node that already carries a shader now always takes the slow path, and removing the border clears the shader (resetting the node to the stage default) instead of leaving it painting. + +Updating an existing shader's props in place now keys off whether the prop exists, not whether its current value is truthy. Previously a prop whose current value was falsy (e.g. a transparent `border-color` of `0`) was skipped, so toggling a focus-ring border from transparent to a visible color on a mounted node was silently dropped and the ring never appeared. diff --git a/.changeset/lightning-input-events.md b/.changeset/lightning-input-events.md new file mode 100644 index 00000000..aad764ba --- /dev/null +++ b/.changeset/lightning-input-events.md @@ -0,0 +1,9 @@ +--- +"@plextv/react-lightning": minor +--- + +fix(input): normalize key events and stop swallowing held-key auto-repeat + +The key pipeline no longer drops OS auto-repeat events. Holding a directional key now keeps bubbling `onKeyDown` events (with `repeat: true`) through the focus tree, so held keys keep navigating and handlers can implement held-key/long-press behavior without re-deriving repeats from raw DOM listeners. The long-press duration is now measured from the initial press (the press timestamp is no longer reset by each repeat), so a held key still resolves to `onLongPress` on release. + +Key events are also normalized into a consistent shape via a new `normalizeKeyEvent` helper: `keyCode` maps to `remoteKey` (falling back to `Keys.Unknown`), `repeat` is preserved, and `preventDefault` is now bound — previously the raw DOM method was copied unbound, so calling `event.preventDefault()` from a handler threw "Illegal invocation". `currentTarget` is now part of the `KeyEvent` type rather than bolted on during bubbling. diff --git a/.changeset/lightning-pressable-focused-state.md b/.changeset/lightning-pressable-focused-state.md new file mode 100644 index 00000000..76c18b2b --- /dev/null +++ b/.changeset/lightning-pressable-focused-state.md @@ -0,0 +1,9 @@ +--- +'@plextv/react-native-lightning': patch +--- + +fix(pressable): expose `focused` to function children + +`Pressable` tracked only `{ pressed }` in state and passed that to its style/children render functions, so `focused` was always `undefined`. Every focus-driven visual built on RN's `({ focused }) => …` contract — focus rings, focus scale — was dead on Lightning. It also only wired `onFocus`/`onBlur` to the node when the consumer passed those callbacks, so a focusable with no listeners tracked nothing. + +`Pressable` now tracks `focused` in state, updates it on focus/blur regardless of whether the consumer passes `onFocus`/`onBlur` (still forwarding to them), and passes `{ focused, pressed }` to its function children — matching React Native. The `pressed` setters no longer replace the whole state object, so a keypress can't clobber `focused`. diff --git a/.changeset/lightning-same-parent-reorder.md b/.changeset/lightning-same-parent-reorder.md new file mode 100644 index 00000000..8d78eca0 --- /dev/null +++ b/.changeset/lightning-same-parent-reorder.md @@ -0,0 +1,6 @@ +--- +'@plextv/react-lightning': patch +'@plextv/react-lightning-plugin-flexbox': patch +--- + +Handle same-parent insertChild as a move so reordered children re-layout in the new order diff --git a/.changeset/lightning-shader-partial-update.md b/.changeset/lightning-shader-partial-update.md new file mode 100644 index 00000000..363f9d9f --- /dev/null +++ b/.changeset/lightning-shader-partial-update.md @@ -0,0 +1,12 @@ +--- +'@plextv/react-lightning': patch +--- + +fix(react-lightning): keep the rounded/border shader on partial style updates + +A partial style update (reanimated pushing just opacity/transform straight to +setProps) recomputed the node's shader from that partial style, found no +borderRadius/border, and cleared the Rounded shader. Any animated rounded node +squared off the moment reanimated touched it. Only rebuild or clear the shader +when the update actually carries a shader-relevant prop (or an explicit shader +override); otherwise keep the existing one. diff --git a/.changeset/lightning-text-intrinsic-sizing.md b/.changeset/lightning-text-intrinsic-sizing.md new file mode 100644 index 00000000..20337784 --- /dev/null +++ b/.changeset/lightning-text-intrinsic-sizing.md @@ -0,0 +1,8 @@ +--- +"@plextv/react-lightning-plugin-flexbox": minor +"@plextv/react-lightning": patch +--- + +feat(flexbox): measure text synchronously in Yoga for wrapping and intrinsic sizing + +Text leaves are now measured during Yoga layout (via msdf font metrics passed through the new `fonts` option) instead of relying solely on the renderer's async texture measurement, so text wraps and sizes correctly within flex layouts. The text node's explicit width/height is cleared when it becomes a measured leaf so the measure function — not a stale renderer-set width — drives its size, and `react-lightning` emits a `textChanged` signal so recycled/updated text re-measures. diff --git a/.changeset/lightning-virtuallist-parity.md b/.changeset/lightning-virtuallist-parity.md new file mode 100644 index 00000000..697390ab --- /dev/null +++ b/.changeset/lightning-virtuallist-parity.md @@ -0,0 +1,7 @@ +--- +"@plextv/react-lightning-components": minor +--- + +feat(virtuallist): getLayout ref API and skipChildFocusScroll opt-out for FlashList parity + +`VirtualListRef` now exposes `getLayout(index)`, returning the scroll-space `{ x, y, width, height }` rectangle of an item (or `undefined` when out of range) — mirroring FlashList's per-item layout query for callers that interpolate row positions against the scroll offset (crossfade/parallax). A new `skipChildFocusScroll` prop opts out of VirtualList's internal focus-follow scroll: a focused child crossing a cell boundary still resolves and persists `focusedIndex`, but the list no longer scrolls the cell into view, letting the app layer own scrolling (e.g. a row that drives `scrollToIndex` from its own authoritative focused index). Both are additive — default behaviour is unchanged. diff --git a/.changeset/lightning-worker-font-url.md b/.changeset/lightning-worker-font-url.md new file mode 100644 index 00000000..b8b548c6 --- /dev/null +++ b/.changeset/lightning-worker-font-url.md @@ -0,0 +1,7 @@ +--- +'@plextv/react-lightning-plugin-flexbox': patch +--- + +fix(flexbox): resolve font atlas URLs before they cross into the Yoga worker + +The Yoga worker is bundled inline (`?worker&inline`), so in a production build its `self.location` is a `blob:` URL. A root-relative atlas URL like `/fonts/x.msdf.json` (what `import.meta.env.BASE_URL` produces) can't resolve against a blob base, so the worker's `fetch` threw "is not a valid URL" and font metrics never loaded — text fell back to single-line, unmeasured layout. It only reproduced in built apps; the dev server serves the worker as a real module, so root-relative URLs resolved fine. Atlas URLs are now resolved to absolute against the document URL on the main thread, before the options cross `postMessage`. diff --git a/.changeset/percent-translate-own-size.md b/.changeset/percent-translate-own-size.md new file mode 100644 index 00000000..d9e14209 --- /dev/null +++ b/.changeset/percent-translate-own-size.md @@ -0,0 +1,6 @@ +--- +'@plextv/react-lightning-plugin-css-transform': patch +'@plextv/react-lightning-plugin-flexbox': patch +--- + +Resolve percentage translateX/translateY against the node's own size (RN semantics). The css-transform converter used parseInt, which stripped the % and treated the number as pixels; the flexbox plugin now stashes the percentage and resolves it at layout readback, once the node's computed size is known, and keeps emitting the node on passes that don't dirty yoga. diff --git a/.changeset/reanimated-dep-inference.md b/.changeset/reanimated-dep-inference.md new file mode 100644 index 00000000..655dbce9 --- /dev/null +++ b/.changeset/reanimated-dep-inference.md @@ -0,0 +1,5 @@ +--- +'@plextv/react-lightning-plugin-reanimated': minor +--- + +Infer reanimated hook dependencies at runtime by tracking shared-value reads. No babel plugin runs on Lightning, so `useAnimatedStyle` / `useDerivedValue` / `useAnimatedReaction` without an explicit dependency array never subscribed to their shared values and only updated on re-renders. Shared values from `useSharedValue` / `makeMutable` now report reads to the active hook, which subscribes to exactly what its updater read (re-collected on every run, so branches are handled). Explicit dependency arrays keep their old behavior. diff --git a/.changeset/rounded-clipping-option.md b/.changeset/rounded-clipping-option.md new file mode 100644 index 00000000..7a614cad --- /dev/null +++ b/.changeset/rounded-clipping-option.md @@ -0,0 +1,5 @@ +--- +'@plextv/react-lightning': patch +--- + +Add an opt-in `roundedClipping` render option: a node with borderRadius + clipping (overflow hidden) clips its children to the rounded rect, like the other RN platforms. Implemented via the renderer's stencil clip (`clipRadius`), so it costs no extra textures, nests, and works for text and images. diff --git a/.changeset/two-geese-stand.md b/.changeset/two-geese-stand.md new file mode 100644 index 00000000..7de6b19c --- /dev/null +++ b/.changeset/two-geese-stand.md @@ -0,0 +1,5 @@ +--- +"@plextv/react-lightning": patch +--- + +fix(text): support FormattedMessage diff --git a/.changeset/virtuallist-pin-cross-axis.md b/.changeset/virtuallist-pin-cross-axis.md new file mode 100644 index 00000000..3de7995b --- /dev/null +++ b/.changeset/virtuallist-pin-cross-axis.md @@ -0,0 +1,5 @@ +--- +'@plextv/react-lightning-components': patch +--- + +VirtualList cells now pin their FlexRoot's cross axis to the cell's cross size when the list's cross size is definite (explicit style, parent cell bounds, or the flex-allocated outer size), so flex children can fill the cell width/height like a native list cell. Content-derived cross sizes stay unpinned to avoid the measure feedback loop. diff --git a/.changeset/virtuallist-pin-section-cross-axis.md b/.changeset/virtuallist-pin-section-cross-axis.md new file mode 100644 index 00000000..31d2eb01 --- /dev/null +++ b/.changeset/virtuallist-pin-section-cross-axis.md @@ -0,0 +1,5 @@ +--- +'@plextv/react-lightning-components': patch +--- + +VirtualList header and footer now pin their FlexRoot's cross axis under the same definiteness rule as the cells, so flex content (e.g. a stretch Column) fills the list width instead of shrink-fitting to its widest child. diff --git a/.changeset/virtuallist-reveal-on-settled.md b/.changeset/virtuallist-reveal-on-settled.md new file mode 100644 index 00000000..b8cc648a --- /dev/null +++ b/.changeset/virtuallist-reveal-on-settled.md @@ -0,0 +1,6 @@ +--- +'@plextv/react-lightning-plugin-flexbox': patch +'@plextv/react-lightning-components': patch +--- + +Reveal VirtualList cells off Yoga's `settled` signal instead of wall-clock timers. A cell now reads its final size once layout has converged to a fixpoint and reports it as authoritative, so the LayoutManager skips its stability window and the RevealGate skips its quiet window. Cuts content-paint latency on the main-thread (worker-off) path; worker mode never emits `settled`, so it falls back to the existing timers. diff --git a/apps/react-lightning-example/src/pages/VirtualListPage.tsx b/apps/react-lightning-example/src/pages/VirtualListPage.tsx index b53e7e21..0cbe18e3 100644 --- a/apps/react-lightning-example/src/pages/VirtualListPage.tsx +++ b/apps/react-lightning-example/src/pages/VirtualListPage.tsx @@ -72,7 +72,6 @@ export const VirtualListPage = () => { { { snapToAlignment="center" drawDistance={100} numColumns={6} - estimatedItemSize={400} ItemSeparatorComponent={() => } contentContainerStyle={{ paddingHorizontal: 25 }} style={{ w: 1670, h: 1080 }} diff --git a/apps/storybook/.storybook/preview.tsx b/apps/storybook/.storybook/preview.tsx index 1fc3251f..92a9eaa4 100644 --- a/apps/storybook/.storybook/preview.tsx +++ b/apps/storybook/.storybook/preview.tsx @@ -33,7 +33,11 @@ const preview: Preview = { context.tags.includes('overrideDecorator') ? ( ) : ( - + ), ], }; diff --git a/apps/storybook/src/react-lightning-components/lists/VirtualList.stories.tsx b/apps/storybook/src/react-lightning-components/lists/VirtualList.stories.tsx index 74146f36..46b0608b 100644 --- a/apps/storybook/src/react-lightning-components/lists/VirtualList.stories.tsx +++ b/apps/storybook/src/react-lightning-components/lists/VirtualList.stories.tsx @@ -43,7 +43,6 @@ const Label = ({ text, w = 500, h = 30 }: { text: string; w?: number; h?: number export const Vertical = () => ( ( ( ( ( ( ( ( export const EmptyList = () => ( } @@ -170,7 +165,6 @@ export const EmptyList = () => ( export const ContentPadding = () => ( ( export const SnapStart = () => ( ( @@ -218,7 +211,6 @@ export const SnapStart = () => ( export const SnapCenter = () => ( ( @@ -238,7 +230,6 @@ export const SnapCenter = () => ( export const SnapEnd = () => ( ( @@ -264,7 +255,6 @@ export const OverrideItemLayout = () => ( { if (index === 0) { @@ -313,7 +303,6 @@ export const InfiniteScroll = () => { return ( (({ focused, in export const ItemTypes = () => ( String(item.id)} getItemType={(item) => item.type} @@ -405,7 +393,6 @@ export const ItemTypes = () => ( export const InitialScrollIndex = () => ( ( @@ -471,7 +458,6 @@ export const ImperativeScrolling = () => { ( { export const DrawDistance = () => ( ( @@ -523,7 +508,6 @@ export const DrawDistance = () => ( export const SlowAnimation = () => ( ( diff --git a/apps/storybook/src/react-native-lightning/views/RoundedClipping.stories.tsx b/apps/storybook/src/react-native-lightning/views/RoundedClipping.stories.tsx new file mode 100644 index 00000000..7cebbbfe --- /dev/null +++ b/apps/storybook/src/react-native-lightning/views/RoundedClipping.stories.tsx @@ -0,0 +1,203 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { useEffect } from 'react'; +import { Text, View } from 'react-native'; +import Animated, { useAnimatedStyle, useSharedValue } from 'react-native-reanimated'; + +/** + * Fixtures for rounded clipping (borderRadius + overflow hidden). + * + * Lightning's `clipping` is a rectangular scissor, so children used to bleed + * square corners over the parent's rounding. The stencil clipRadius path clips + * them to the rounded rect instead. Every fixture is a visual pass/fail. + */ + +const RADIUS = 28; + +const Fixture = ({ overflowHidden }: { overflowHidden: boolean }) => ( + + + {/* Full-bleed child; its square corners would cover the parent's rounding. */} + + {/* Bottom strip crossing both bottom corners (the progress-bar case). */} + + + {overflowHidden + ? 'all four corners must be rounded' + : 'control: no overflow hidden, corners stay square'} + + + +); + +// borderRadius far past the node size (the borderRadius: 9999 circle idiom). +// The renderer must clamp the clip radius to half the node size; unclamped it +// breaks the rounded-rect SDF and clips the whole subtree away. +const MaxRadiusFixture = () => ( + + + + R + + + + + circle + pill, nothing vanishes + + + +); + +// Content that keeps changing INSIDE the clipped subtree: a block sweeping +// horizontally and a block whose opacity pulses. If either freezes, RTT +// invalidation is broken for that update kind. +const AnimatedFixture = () => { + const x = useSharedValue(0); + const opacity = useSharedValue(1); + + useEffect(() => { + let raf = 0; + const t0 = Date.now(); + const step = () => { + const t = ((Date.now() - t0) % 4000) / 4000; + + x.value = (t < 0.5 ? t * 2 : (1 - t) * 2) * 300; + opacity.value = t < 0.5 ? 1 : 0.15; + raf = requestAnimationFrame(step); + }; + + raf = requestAnimationFrame(step); + + return () => cancelAnimationFrame(raf); + }, [x, opacity]); + + // Explicit deps: shared-value listeners attach from this array (no babel + // plugin to infer them). + const sweepStyle = useAnimatedStyle(() => ({ transform: [{ translateX: x.value }] }), [x]); + const pulseStyle = useAnimatedStyle(() => ({ opacity: opacity.value }), [opacity]); + + return ( + + + + + + + ); +}; + +export default { + title: 'react-native-lightning/Views/Rounded Clipping', + component: Fixture, + tags: ['reactNative'], + parameters: { canvasOptions: { roundedClipping: true } }, +} as Meta; + +type Story = StoryObj; + +export const OverflowHidden: Story = { + args: { overflowHidden: true }, +}; + +export const Control_NoOverflow: Story = { + args: { overflowHidden: false }, +}; + +export const MaxRadius: Story = { + render: () => , +}; + +export const AnimatedContent: Story = { + render: () => , +}; diff --git a/apps/storybook/vite.config.mjs b/apps/storybook/vite.config.mjs index c32f0f41..79ec8779 100644 --- a/apps/storybook/vite.config.mjs +++ b/apps/storybook/vite.config.mjs @@ -41,6 +41,9 @@ const config = defineConfig((env) => ({ // dep optimizer can't resolve. Exclude it so Vite handles it via its // normal transform pipeline instead. exclude: ['@plextv/react-lightning-plugin-flexbox'], + // CJS-only; without pre-bundling it is served as raw CJS and named + // imports (reanimated's controlEdgeToEdgeValues) fail to resolve. + include: ['react-native-is-edge-to-edge'], }, server: { diff --git a/packages/plugin-css-transform/src/convertCSSStyleToLightning.spec.ts b/packages/plugin-css-transform/src/convertCSSStyleToLightning.spec.ts new file mode 100644 index 00000000..4c72d8ae --- /dev/null +++ b/packages/plugin-css-transform/src/convertCSSStyleToLightning.spec.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; + +import { convertCSSStyleToLightning } from './convertCSSStyleToLightning'; + +describe('convertCSSStyleToLightning border radius', () => { + it('passes a uniform borderRadius through unchanged', () => { + expect(convertCSSStyleToLightning({ borderRadius: 8 })?.borderRadius).toBe( + 8, + ); + }); + + it('expands a single corner longhand into a [tl, tr, br, bl] array', () => { + expect( + convertCSSStyleToLightning({ borderTopRightRadius: 8 })?.borderRadius, + ).toEqual([0, 8, 0, 0]); + }); + + it('maps logical start/end corners onto physical corners (LTR)', () => { + expect( + convertCSSStyleToLightning({ + borderTopEndRadius: 8, + borderBottomStartRadius: 8, + })?.borderRadius, + ).toEqual([0, 8, 0, 8]); + }); + + it('uses the uniform borderRadius as the base for unspecified corners', () => { + expect( + convertCSSStyleToLightning({ borderRadius: 4, borderTopEndRadius: 8 }) + ?.borderRadius, + ).toEqual([4, 8, 4, 4]); + }); + + it('drops the per-corner longhands from the output', () => { + const result = convertCSSStyleToLightning({ + borderTopEndRadius: 8, + }) as Record; + expect(result.borderTopEndRadius).toBeUndefined(); + }); +}); diff --git a/packages/plugin-css-transform/src/convertCSSStyleToLightning.ts b/packages/plugin-css-transform/src/convertCSSStyleToLightning.ts index c4f25579..6c4673c7 100644 --- a/packages/plugin-css-transform/src/convertCSSStyleToLightning.ts +++ b/packages/plugin-css-transform/src/convertCSSStyleToLightning.ts @@ -1,10 +1,75 @@ -import type { LightningElementStyle, LightningTextElementStyle } from '@plextv/react-lightning'; - +import type { + LightningElementStyle, + LightningTextElementStyle, +} from '@plextv/react-lightning'; import type { AllStyleProps } from './types/ReactStyle'; import { flattenStyles } from './utils/flattenStyles'; import { htmlColorToLightningColor } from './utils/htmlColorToLightningColor'; +import { parseLinearGradient } from './utils/parseLinearGradient'; import { parseTransform } from './utils/parseTransform'; +// RN exposes per-corner radius longhands; Lightning's Rounded shader wants a single +// borderRadius (number, or [tl, tr, br, bl]). Expand the longhands so they aren't dropped. +// Logical start/end map to physical left/right (LTR only, which is all the app ships). +// Non-numeric values (animated nodes, '50%') aren't supported by the shader, so they're skipped. +interface CornerRadii { + borderRadius?: unknown; + borderTopLeftRadius?: unknown; + borderTopRightRadius?: unknown; + borderBottomLeftRadius?: unknown; + borderBottomRightRadius?: unknown; + borderTopStartRadius?: unknown; + borderTopEndRadius?: unknown; + borderBottomStartRadius?: unknown; + borderBottomEndRadius?: unknown; +} + +function resolveBorderRadius( + radii: CornerRadii, +): number | [number, number, number, number] | undefined { + const { + borderRadius, + borderTopLeftRadius, + borderTopRightRadius, + borderBottomLeftRadius, + borderBottomRightRadius, + borderTopStartRadius, + borderTopEndRadius, + borderBottomStartRadius, + borderBottomEndRadius, + } = radii; + + const num = (value: unknown): number | undefined => + typeof value === 'number' ? value : undefined; + + const topLeft = num(borderTopLeftRadius) ?? num(borderTopStartRadius); + const topRight = num(borderTopRightRadius) ?? num(borderTopEndRadius); + const bottomRight = + num(borderBottomRightRadius) ?? num(borderBottomEndRadius); + const bottomLeft = + num(borderBottomLeftRadius) ?? num(borderBottomStartRadius); + + const base = num(borderRadius); + + if ( + topLeft == null && + topRight == null && + bottomRight == null && + bottomLeft == null + ) { + return base; + } + + const fallback = base ?? 0; + + return [ + topLeft ?? fallback, + topRight ?? fallback, + bottomRight ?? fallback, + bottomLeft ?? fallback, + ]; +} + export function convertCSSStyleToLightning( style: AllStyleProps, ): LightningElementStyle | undefined { @@ -28,6 +93,17 @@ export function convertCSSStyleToLightning( transform, width, height, + backgroundImage, + experimental_backgroundImage, + borderRadius, + borderTopLeftRadius, + borderTopRightRadius, + borderBottomLeftRadius, + borderBottomRightRadius, + borderTopStartRadius, + borderTopEndRadius, + borderBottomStartRadius, + borderBottomEndRadius, ...otherStyles } = flattenStyles(style); const finalStyle = { @@ -46,8 +122,24 @@ export function convertCSSStyleToLightning( finalStyle.color = color; } + const gradientValue = + typeof backgroundImage === 'string' + ? backgroundImage + : typeof experimental_backgroundImage === 'string' + ? experimental_backgroundImage + : undefined; + + if (gradientValue != null) { + const gradient = parseLinearGradient(gradientValue); + + if (gradient) { + finalStyle.linearGradient = gradient; + } + } + if (shadowColor != null) { - (finalStyle as LightningTextElementStyle).shadowColor = htmlColorToLightningColor(shadowColor); + (finalStyle as LightningTextElementStyle).shadowColor = + htmlColorToLightningColor(shadowColor); } if (border != null || borderWidth != null || borderColor != null) { @@ -61,7 +153,7 @@ export function convertCSSStyleToLightning( finalStyle.border = { w: w != null ? Number.parseInt(w, 10) : 0, - color: htmlColorToLightningColor(c), + color: htmlColorToLightningColor(c) ?? 0, }; } else if (border) { finalStyle.border = border; @@ -77,7 +169,7 @@ export function convertCSSStyleToLightning( } if (borderColor) { - finalStyle.border.color = htmlColorToLightningColor(borderColor); + finalStyle.border.color = htmlColorToLightningColor(borderColor) ?? 0; } } @@ -100,7 +192,9 @@ export function convertCSSStyleToLightning( if (otherStyles.top != null) { finalStyle.y = - typeof otherStyles.top === 'number' ? otherStyles.top : Number.parseInt(otherStyles.top, 10); + typeof otherStyles.top === 'number' + ? otherStyles.top + : Number.parseInt(otherStyles.top, 10); } if (fontWeight != null) { @@ -111,7 +205,8 @@ export function convertCSSStyleToLightning( } if (transform != null) { - const { scaleX, scaleY, rotation, ...translateTransforms } = parseTransform(transform); + const { scaleX, scaleY, rotation, ...translateTransforms } = + parseTransform(transform); if (scaleX != null) { finalStyle.scaleX = scaleX; @@ -129,10 +224,29 @@ export function convertCSSStyleToLightning( } // Disabled for now as some components set overflow to hidden while not having their size correctly calculated - if (overflow === 'hidden' || overflowX === 'hidden' || overflowY === 'hidden') { + if ( + overflow === 'hidden' || + overflowX === 'hidden' || + overflowY === 'hidden' + ) { finalStyle.clipping = true; } + const cornerRadii = resolveBorderRadius({ + borderRadius, + borderTopLeftRadius, + borderTopRightRadius, + borderBottomLeftRadius, + borderBottomRightRadius, + borderTopStartRadius, + borderTopEndRadius, + borderBottomStartRadius, + borderBottomEndRadius, + }); + if (cornerRadii != null) { + finalStyle.borderRadius = cornerRadii; + } + if (width != null) { finalStyle.w = width as number; } diff --git a/packages/plugin-css-transform/src/index.ts b/packages/plugin-css-transform/src/index.ts index 9d545c20..5c09855c 100644 --- a/packages/plugin-css-transform/src/index.ts +++ b/packages/plugin-css-transform/src/index.ts @@ -12,6 +12,8 @@ export { parseTransform } from './utils/parseTransform'; const CSS_HANDLED_STYLE_PROPS: ReadonlySet = new Set([ 'backgroundColor', + 'backgroundImage', + 'experimental_backgroundImage', 'color', 'border', 'borderWidth', diff --git a/packages/plugin-css-transform/src/utils/convertCSSTransformToLightning.spec.ts b/packages/plugin-css-transform/src/utils/convertCSSTransformToLightning.spec.ts new file mode 100644 index 00000000..869b1b46 --- /dev/null +++ b/packages/plugin-css-transform/src/utils/convertCSSTransformToLightning.spec.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; + +import { convertCSSTransformToLightning } from './convertCSSTransformToLightning'; + +describe('convertCSSTransformToLightning', () => { + it('parses a pixel translateX to a number', () => { + expect(convertCSSTransformToLightning('translateX', 50)).toEqual({ + translateX: 50, + }); + expect(convertCSSTransformToLightning('translateX', '50px')).toEqual({ + translateX: 50, + }); + }); + + it('preserves a percentage translate as a string (resolved at layout)', () => { + // parseInt used to strip the % and leave 50 (px), mispositioning the node. + expect(convertCSSTransformToLightning('translateX', '50%')).toEqual({ + translateX: '50%', + }); + expect(convertCSSTransformToLightning('translateY', '-50%')).toEqual({ + translateY: '-50%', + }); + }); + + it('preserves percentages in the translate shorthand', () => { + expect(convertCSSTransformToLightning('translate', '50%,10%')).toEqual({ + translateX: '50%', + translateY: '10%', + }); + }); +}); diff --git a/packages/plugin-css-transform/src/utils/convertCSSTransformToLightning.ts b/packages/plugin-css-transform/src/utils/convertCSSTransformToLightning.ts index dc7cbfcd..e4b7b45c 100644 --- a/packages/plugin-css-transform/src/utils/convertCSSTransformToLightning.ts +++ b/packages/plugin-css-transform/src/utils/convertCSSTransformToLightning.ts @@ -2,6 +2,40 @@ import type { Transform } from '@plextv/react-lightning-plugin-flexbox'; import { convertRotationValue } from './convertRotationValue'; +// translateX/Y accept a percentage string (of the node's own size, resolved at +// layout readback) or pixels. parseInt would silently drop the % and treat the +// number as px, mispositioning the node. +function parseTranslateValue(value: string | number): number | `${number}%` { + if (typeof value === 'number') { + return value; + } + + const trimmed = value.trim(); + + return trimmed.endsWith('%') + ? (trimmed as `${number}%`) + : Number.parseInt(trimmed, 10); +} + +function getXYTranslate( + value: string | number | number[], +): [number | `${number}%`, number | `${number}%`] { + if (Array.isArray(value)) { + const x = value[0] ?? 0; + + return [x, value[1] ?? x]; + } + + if (typeof value === 'number') { + return [value, value]; + } + + const [xString, yString] = value.split(','); + const x = xString != null ? parseTranslateValue(xString) : 0; + + return [x, yString == null ? x : parseTranslateValue(yString)]; +} + function getValue( value: string | number | number[], defaultValue: number, @@ -51,7 +85,7 @@ export function convertCSSTransformToLightning( for (const key in transformValue) { const value = (transformValue as Record)[key]; - if (value) { + if (value != null) { const result = convertCSSTransformToLightning(key, value); Object.assign(transformResult, result); @@ -64,17 +98,21 @@ export function convertCSSTransformToLightning( switch (transformType) { case 'translate': { - const [x, y] = getXYValue(transformValue, 0, Number.parseInt); + const [x, y] = getXYTranslate(transformValue); transformResult.translateX = x; transformResult.translateY = y; } break; case 'translateX': - transformResult.translateX = getValue(transformValue, 0, Number.parseInt); + transformResult.translateX = parseTranslateValue( + transformValue as string | number, + ); break; case 'translateY': - transformResult.translateY = getValue(transformValue, 0, Number.parseInt); + transformResult.translateY = parseTranslateValue( + transformValue as string | number, + ); break; case 'scale': { diff --git a/packages/plugin-css-transform/src/utils/htmlColorToLightningColor.test.ts b/packages/plugin-css-transform/src/utils/htmlColorToLightningColor.test.ts index 53865b12..a4ebc2ca 100644 --- a/packages/plugin-css-transform/src/utils/htmlColorToLightningColor.test.ts +++ b/packages/plugin-css-transform/src/utils/htmlColorToLightningColor.test.ts @@ -95,4 +95,10 @@ describe('htmlColorToLightningColor', () => { expect(run).toThrow('Invalid hex value'); }); + + it('should return undefined for unresolvable css keyword colors', () => { + for (const value of ['inherit', 'initial', 'unset', 'revert', 'currentColor', 'CurrentColor']) { + expect(htmlColorToLightningColor(value)).toBeUndefined(); + } + }); }); diff --git a/packages/plugin-css-transform/src/utils/htmlColorToLightningColor.ts b/packages/plugin-css-transform/src/utils/htmlColorToLightningColor.ts index 59e97d3f..ffd8dbf4 100644 --- a/packages/plugin-css-transform/src/utils/htmlColorToLightningColor.ts +++ b/packages/plugin-css-transform/src/utils/htmlColorToLightningColor.ts @@ -5,6 +5,9 @@ import { htmlColorCodes } from './htmlColorCodes'; const hexRgbRegex = /^#?([a-f0-9]{6})$/i; const hexShortRgbRegex = /^#?([a-f0-9]{3})$/i; const rgbRegex = /^rgba?\(([0-9.]+)[, ]+([0-9.]+)[, ]+([0-9.]+)[, ]*([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 { if (overrideAlpha == null) { @@ -24,7 +27,7 @@ function withAlphaOverride(color: number, overrideAlpha?: number | string): numb export function htmlColorToLightningColor( color?: ColorValue | number, overrideAlpha?: number | string, -): number { +): number | undefined { if (!color) { return 0; } @@ -69,5 +72,9 @@ export function htmlColorToLightningColor( return withAlphaOverride(Number.parseInt(rgbText, 16), overrideAlpha); } + if (cssKeywordColorRegex.test(colorLower)) { + return undefined; + } + throw new Error(`Invalid hex value specified for conversion: ${color.toString()}`); } diff --git a/packages/plugin-css-transform/src/utils/parseLinearGradient.spec.ts b/packages/plugin-css-transform/src/utils/parseLinearGradient.spec.ts new file mode 100644 index 00000000..f5e8b086 --- /dev/null +++ b/packages/plugin-css-transform/src/utils/parseLinearGradient.spec.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; + +import { parseLinearGradient } from './parseLinearGradient'; + +describe('parseLinearGradient', () => { + it('parses the player controls gradient (to bottom, rgba stops)', () => { + const result = parseLinearGradient( + 'linear-gradient(to bottom, rgba(0,0,0,0) 0%, rgba(0,0,0,0.56) 40%, rgba(0,0,0,0.76) 60%, rgba(0,0,0,1) 100%)', + ); + + expect(result).toEqual({ + colors: [0x00000000, 0x0000008f, 0x000000c2, 0x000000ff], + stops: [0, 0.4, 0.6, 1], + angle: 0, + }); + }); + + it('defaults to "to bottom" (angle 0) when no direction is given', () => { + const result = parseLinearGradient('linear-gradient(rgba(0,0,0,0) 0%, rgba(0,0,0,1) 100%)'); + + expect(result?.angle).toBe(0); + }); + + it('maps "to top" to PI', () => { + const result = parseLinearGradient('linear-gradient(to top, #000 0%, #fff 100%)'); + + expect(result?.angle).toBeCloseTo(Math.PI); + }); + + it('maps a degree angle (CSS 90deg / to right)', () => { + const result = parseLinearGradient('linear-gradient(90deg, #000, #fff)'); + + // CSS 90deg -> lightning (90 - 180) deg, normalised to (3/2)PI + expect(result?.angle).toBeCloseTo((3 * Math.PI) / 2); + }); + + it('evenly distributes stops that omit positions', () => { + const result = parseLinearGradient('linear-gradient(to bottom, red, lime, blue)'); + + expect(result?.stops).toEqual([0, 0.5, 1]); + expect(result?.colors).toEqual([0xff0000ff, 0x00ff00ff, 0x0000ffff]); + }); + + it('interpolates a missing interior stop', () => { + const result = parseLinearGradient( + 'linear-gradient(to bottom, #000 0%, #333, #666, #fff 100%)', + ); + + expect(result?.stops).toEqual([0, 1 / 3, 2 / 3, 1]); + }); + + it('handles rgba with spaces after commas', () => { + const result = parseLinearGradient( + 'linear-gradient(to bottom, rgba(0, 0, 0, 0.8) 0%, rgba(0, 0, 0, 0) 87.5%)', + ); + + expect(result?.colors).toEqual([0x000000cc, 0x00000000]); + expect(result?.stops).toEqual([0, 0.875]); + }); + + it('returns undefined for non-linear-gradient values', () => { + expect(parseLinearGradient('url(foo.png)')).toBeUndefined(); + expect(parseLinearGradient('radial-gradient(#000, #fff)')).toBeUndefined(); + expect(parseLinearGradient(undefined)).toBeUndefined(); + }); + + it('returns undefined when fewer than two colors resolve', () => { + expect(parseLinearGradient('linear-gradient(#000)')).toBeUndefined(); + }); +}); diff --git a/packages/plugin-css-transform/src/utils/parseLinearGradient.ts b/packages/plugin-css-transform/src/utils/parseLinearGradient.ts new file mode 100644 index 00000000..49bb736a --- /dev/null +++ b/packages/plugin-css-transform/src/utils/parseLinearGradient.ts @@ -0,0 +1,237 @@ +import { htmlColorToLightningColor } from './htmlColorToLightningColor'; + +export interface LinearGradientShaderProps { + colors: number[]; + stops: number[]; + angle: number; +} + +// CSS keyword directions in CSS degrees (0deg = to top, 90deg = to right, ...). +const KEYWORD_ANGLES: Record = { + top: 0, + right: 90, + bottom: 180, + left: 270, + 'top right': 45, + 'right top': 45, + 'bottom right': 135, + 'right bottom': 135, + 'bottom left': 225, + 'left bottom': 225, + 'top left': 315, + 'left top': 315, +}; + +const ANGLE_UNIT_TO_DEG: Record = { + deg: 1, + grad: 0.9, + rad: 180 / Math.PI, + turn: 360, +}; + +// Lightning's LinearGradient shader points top-to-bottom at angle 0, which is +// CSS "to bottom" (180deg), and rotates the same way. So lightning = css - 180, +// normalised into [0, 2π) so equivalent directions map to one stable value. +function cssDegToLightningRadians(cssDeg: number): number { + const radians = ((cssDeg - 180) * Math.PI) / 180; + const twoPi = Math.PI * 2; + + return ((radians % twoPi) + twoPi) % twoPi; +} + +function parseDirection(token: string): number | undefined { + const angleMatch = /^(-?[\d.]+)(deg|grad|rad|turn)$/.exec(token); + + if (angleMatch) { + const value = Number.parseFloat(angleMatch[1] ?? ''); + const factor = ANGLE_UNIT_TO_DEG[angleMatch[2] ?? '']; + + if (factor == null || Number.isNaN(value)) { + return undefined; + } + + return cssDegToLightningRadians(value * factor); + } + + if (token.startsWith('to ')) { + const sides = token.slice(3).trim().split(/\s+/).join(' '); + const cssDeg = KEYWORD_ANGLES[sides]; + + if (cssDeg != null) { + return cssDegToLightningRadians(cssDeg); + } + } + + return undefined; +} + +// Split on commas that aren't nested inside parens (rgba(...), hsl(...)). +function splitTopLevel(input: string): string[] { + const parts: string[] = []; + let depth = 0; + let start = 0; + + for (let i = 0; i < input.length; i++) { + const char = input[i]; + + if (char === '(') { + depth++; + } else if (char === ')') { + depth--; + } else if (char === ',' && depth === 0) { + parts.push(input.slice(start, i)); + start = i + 1; + } + } + + parts.push(input.slice(start)); + + return parts.map((part) => part.trim()).filter(Boolean); +} + +// Fill missing stops the way CSS does: first defaults to 0, last to 1, runs of +// omitted stops are spread evenly between their defined neighbours, and the +// sequence is clamped to be non-decreasing. +function normalizeStops(positions: (number | undefined)[]): number[] { + const out = positions.slice(); + const lastIndex = out.length - 1; + + if (out[0] == null) { + out[0] = 0; + } + + if (out[lastIndex] == null) { + out[lastIndex] = 1; + } + + let i = 0; + + while (i < out.length) { + if (out[i] != null) { + i++; + continue; + } + + const startIndex = i - 1; + const startVal = out[startIndex]; + let end = i; + + while (end < out.length && out[end] == null) { + end++; + } + + const endVal = out[end]; + + if (startVal != null && endVal != null) { + const span = end - startIndex; + + for (let k = i; k < end; k++) { + out[k] = startVal + ((endVal - startVal) * (k - startIndex)) / span; + } + } + + i = end; + } + + let prev = out[0] ?? 0; + + return out.map((value) => { + const resolved = value ?? prev; + const clamped = resolved < prev ? prev : resolved; + + prev = clamped; + + return clamped; + }); +} + +function parseStopPosition(raw: string | undefined): number | undefined { + if (raw == null) { + return undefined; + } + + const match = /^(-?[\d.]+)(%|px)?$/.exec(raw); + const value = match?.[1]; + + if (value == null) { + return undefined; + } + + // px positions need the element size to normalise, which we don't have here. + // Fall back to even distribution for those (rare in practice). + if (match?.[2] === 'px') { + return undefined; + } + + return Number.parseFloat(value) / 100; +} + +/** + * Parse a CSS `linear-gradient(...)` value into the props Lightning's + * LinearGradient shader expects. Returns undefined for anything that isn't a + * linear gradient (url(), radial-gradient, etc.) or that resolves to fewer than + * two colors. + */ +export function parseLinearGradient( + value: string | undefined | null, +): LinearGradientShaderProps | undefined { + if (!value || typeof value !== 'string') { + return undefined; + } + + const match = /^\s*linear-gradient\((.*)\)\s*$/is.exec(value.trim()); + const inner = match?.[1]; + + if (inner == null) { + return undefined; + } + + const segments = splitTopLevel(inner); + const first = segments[0]; + + if (first == null) { + return undefined; + } + + let angle = cssDegToLightningRadians(180); + const direction = parseDirection(first); + + if (direction != null) { + angle = direction; + segments.shift(); + } + + const colors: number[] = []; + const positions: (number | undefined)[] = []; + + for (const segment of segments) { + // Strip a trailing position token, leaving the color (which may itself + // contain spaces, e.g. `rgba(0, 0, 0, 0.8)`). + const posMatch = /\s+(-?[\d.]+(?:%|px)?)\s*$/.exec(segment); + const colorText = posMatch ? segment.slice(0, posMatch.index).trim() : segment; + + try { + const color = htmlColorToLightningColor(colorText); + + if (color == null) { + return undefined; + } + + colors.push(color); + } catch { + return undefined; + } + + positions.push(parseStopPosition(posMatch?.[1])); + } + + if (colors.length < 2) { + return undefined; + } + + return { + colors, + stops: normalizeStops(positions), + angle, + }; +} diff --git a/packages/plugin-flexbox/bench/.gitignore b/packages/plugin-flexbox/bench/.gitignore new file mode 100644 index 00000000..65721f99 --- /dev/null +++ b/packages/plugin-flexbox/bench/.gitignore @@ -0,0 +1 @@ +.baseline.local.json diff --git a/packages/plugin-flexbox/bench/README.md b/packages/plugin-flexbox/bench/README.md new file mode 100644 index 00000000..59318be9 --- /dev/null +++ b/packages/plugin-flexbox/bench/README.md @@ -0,0 +1,44 @@ +# Layout benchmark + +Deterministic layout metrics for a few synthetic pages (home row-list, details +hero+rows, poster grid, EPG grid) run through the real `YogaManager` / +`LightningManager`. No renderer, no emulator, no network: it measures layout +work, not perceived smoothness. + +## Workflow: baseline, then delta + +Absolute numbers vary by machine, so the delta is what matters. Before you start +a fix or feature, snapshot a local baseline at your branch point; after your +change, compare: + +``` +pnpm --filter @plextv/react-lightning-plugin-flexbox bench:save # snapshot HEAD (local, gitignored) +# ... make your change ... +pnpm --filter @plextv/react-lightning-plugin-flexbox bench # delta vs your snapshot +``` + +`bench:save` writes `.baseline.local.json` (gitignored, per-engineer). Running +`bench` with a snapshot present prints each metric with its delta; without one +it just prints current numbers and tells you to snapshot first. + +## Metrics + +- `nodeCount` — total layout nodes created over the scenario. +- `layoutPasses` — `render` events emitted (calculateLayout flushes). +- `settles` — `settled` events (one per converged mount/scroll step). +- `maxPassesToSettle` — worst-case passes a single step took to converge. +- `reflows` — a node's computed size changing after its first sizing. +- `textRemeasures` — grow-only text re-measures beyond each node's first (the churn phase 2 should cut). +- `ms` — wall time, reported only, never gated. + +## CI gate + +`bench.test.ts` asserts the structural counts equal the committed +`baseline.json`. Those counts are algorithmic (a fixed fixture lays out the same +everywhere), so a committed baseline is valid across machines and catches +unintended count changes in review. Only `ms` is machine-dependent, and it is +never gated. If a change moves the counts on purpose (e.g. fewer passes after an +optimization), rerun `bench:update` and commit the new baseline in the same change. + +The fixtures are synthetic proxies modeled on the shipped pages' structure, not +their pixels. diff --git a/packages/plugin-flexbox/bench/atlas.ts b/packages/plugin-flexbox/bench/atlas.ts new file mode 100644 index 00000000..d8155dca --- /dev/null +++ b/packages/plugin-flexbox/bench/atlas.ts @@ -0,0 +1,30 @@ +import type { AtlasData } from '../src/text/FontMetricsStore'; + +// Deterministic synthetic atlas: every ASCII glyph is a fixed width so text +// measurement is reproducible without shipping a real font. Metrics mirror the +// integration test's atlas (fontSize 20 -> 10px/glyph, line height 20px). +const BENCH_FONT_FAMILY = 'Bench'; + +function buildChars(): AtlasData['chars'] { + const chars: AtlasData['chars'] = []; + // Printable ASCII 32..126, uniform advance, plus '?' fallback at 63. + for (let id = 32; id <= 126; id++) { + chars.push({ id, xadvance: 10, xoffset: 0, yoffset: 0, width: 8, height: 8 }); + } + return chars; +} + +export const benchAtlas: AtlasData = { + info: { size: 10, face: BENCH_FONT_FAMILY }, + common: { lineHeight: 12, base: 8 }, + lightningMetrics: { ascender: 800, descender: -200, lineGap: 0, unitsPerEm: 1000 }, + chars: buildChars(), + kernings: [], +}; + +export const BENCH_FONT = BENCH_FONT_FAMILY; + +// data: URL so LightningManager.init's real font-load path works offline. +export const benchAtlasUrl = `data:application/json;base64,${Buffer.from( + JSON.stringify(benchAtlas), +).toString('base64')}`; diff --git a/packages/plugin-flexbox/bench/baseline.json b/packages/plugin-flexbox/bench/baseline.json new file mode 100644 index 00000000..50411c48 --- /dev/null +++ b/packages/plugin-flexbox/bench/baseline.json @@ -0,0 +1,6 @@ +{ + "home": { "nodeCount": 875, "layoutPasses": 50, "settles": 25, "maxPassesToSettle": 2, "reflows": 0, "textRemeasures": 425 }, + "details": { "nodeCount": 670, "layoutPasses": 40, "settles": 20, "maxPassesToSettle": 2, "reflows": 0, "textRemeasures": 326 }, + "grid": { "nodeCount": 570, "layoutPasses": 60, "settles": 30, "maxPassesToSettle": 2, "reflows": 0, "textRemeasures": 180 }, + "epg": { "nodeCount": 832, "layoutPasses": 64, "settles": 32, "maxPassesToSettle": 2, "reflows": 0, "textRemeasures": 512 } +} diff --git a/packages/plugin-flexbox/bench/bench.test.ts b/packages/plugin-flexbox/bench/bench.test.ts new file mode 100644 index 00000000..328f5a75 --- /dev/null +++ b/packages/plugin-flexbox/bench/bench.test.ts @@ -0,0 +1,24 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +import { collect, diff } from './run'; + +// The gate: layout metrics for the fake pages must match the committed +// baseline. A layout change that moves them fails here on purpose. If the move +// is intended (e.g. fewer passes after an optimization), rebaseline with +// `tsx bench/run.ts --update` and commit the new baseline in the same change. +describe('layout benchmark', () => { + it('matches the committed baseline (no unexplained drift)', async () => { + const here = dirname(fileURLToPath(import.meta.url)); + const baseline = JSON.parse( + readFileSync(join(here, 'baseline.json'), 'utf8'), + ); + + const current = await collect(); + const drift = diff(current, baseline); + + expect(drift).toEqual([]); + }); +}); diff --git a/packages/plugin-flexbox/bench/cli.ts b/packages/plugin-flexbox/bench/cli.ts new file mode 100644 index 00000000..bab93493 --- /dev/null +++ b/packages/plugin-flexbox/bench/cli.ts @@ -0,0 +1,5 @@ +import { main } from './run'; + +// tsx entry (kept out of run.ts so the vitest gate can import it without +// tripping the transform target's no-top-level-await rule). +await main(); diff --git a/packages/plugin-flexbox/bench/fixtures.ts b/packages/plugin-flexbox/bench/fixtures.ts new file mode 100644 index 00000000..db03cfcf --- /dev/null +++ b/packages/plugin-flexbox/bench/fixtures.ts @@ -0,0 +1,123 @@ +import { BENCH_FONT } from './atlas'; + +// Synthetic proxies for the pages we ship, modeled on their real layout shape +// (not their pixels). Each cell is an independent flex root, matching how +// VirtualListCell wraps every row/tile in a FlexRoot. The harness mounts a +// window of cells, then recycles top->bottom to model a browse scroll. + +export type NodeSpec = { + style?: Record; + text?: string; + fontSize?: number; + children?: NodeSpec[]; +}; + +export type Fixture = { + name: string; + // Structural note shown in the report. + shape: string; + // Builds the Nth cell's content subtree (deterministic in n). + cell: (n: number) => NodeSpec; + // Cross-axis size the harness pins on each cell root (viewport width or + // tile width), or undefined to let the cell shrink-to-content. + cellCrossSize?: number; + window: number; + scrollSteps: number; +}; + +const title = (text: string, fontSize = 24): NodeSpec => ({ + style: { fontFamily: BENCH_FONT, fontSize, maxLines: 1 }, + text, + fontSize, +}); + +const line = (text: string, fontSize = 18): NodeSpec => ({ + style: { fontFamily: BENCH_FONT, fontSize, maxLines: 2 }, + text, + fontSize, +}); + +// A poster tile: container column holding an image box and 1-2 text lines. +// Text has no explicit width, so it shrink-wraps and the column height is +// content-driven (the exact case that makes cells grow after mount). +const tile = (n: number, withSubtitle = true): NodeSpec => ({ + style: { display: 'flex', flexDirection: 'column', gap: 8 }, + children: [ + { style: { w: 214, h: 320 } }, // artwork box + title(`Title ${n}`), + ...(withSubtitle ? [line(`A synthetic subtitle for item number ${n}`)] : []), + ], +}); + +// A horizontal row of tiles (Home / row-scroller). +const row = (n: number, tiles: number): NodeSpec => ({ + style: { display: 'flex', flexDirection: 'row', gap: 24 }, + children: Array.from({ length: tiles }, (_, i) => tile(n * tiles + i)), +}); + +export const fixtures: Fixture[] = [ + { + name: 'home', + shape: 'vertical list of horizontal tile rows (section title + row)', + cell: (n) => ({ + style: { display: 'flex', flexDirection: 'column', gap: 16 }, + children: [title(`Section ${n}`, 28), row(n, 8)], + }), + cellCrossSize: 1920, + window: 5, + scrollSteps: 20, + }, + { + name: 'details', + shape: 'hero block + metadata lines + related rows', + cell: (n) => + n === 0 + ? { + style: { display: 'flex', flexDirection: 'column', gap: 12 }, + children: [ + { style: { w: 1920, h: 720 } }, // hero art + title('The Synthetic Feature Presentation', 48), + line('2026 1h 54m PG', 20), + line( + 'Every night a shepherd reads aloud a murder mystery to his flock. When he is found dead, the sheep set out to solve it themselves before the next full moon.', + 22, + ), + ], + } + : { + style: { display: 'flex', flexDirection: 'column', gap: 16 }, + children: [title(`Related ${n}`, 28), row(n, 8)], + }, + cellCrossSize: 1920, + window: 4, + scrollSteps: 16, + }, + { + name: 'grid', + shape: 'uniform poster grid (row of fixed-size tiles per cell)', + cell: (n) => ({ + style: { display: 'flex', flexDirection: 'row', gap: 24 }, + children: Array.from({ length: 6 }, (_, i) => tile(n * 6 + i, false)), + }), + cellCrossSize: 1920, + window: 6, + scrollSteps: 24, + }, + { + name: 'epg', + shape: 'channel row: logo + horizontal airing blocks with title + time', + cell: (n) => ({ + style: { display: 'flex', flexDirection: 'row', gap: 4 }, + children: [ + { style: { w: 160, h: 96 } }, // channel logo + ...Array.from({ length: 8 }, (_, i) => ({ + style: { display: 'flex', flexDirection: 'column', gap: 4, w: 300, h: 96 }, + children: [title(`Program ${n}-${i}`, 20), line('8:00 PM - 9:00 PM', 16)], + })), + ], + }), + cellCrossSize: 1920, + window: 8, + scrollSteps: 24, + }, +]; diff --git a/packages/plugin-flexbox/bench/harness.ts b/packages/plugin-flexbox/bench/harness.ts new file mode 100644 index 00000000..5a37a4b5 --- /dev/null +++ b/packages/plugin-flexbox/bench/harness.ts @@ -0,0 +1,210 @@ +import { LightningManager } from '../src/LightningManager'; +import type { YogaManager } from '../src/YogaManager'; +import { BENCH_FONT, benchAtlasUrl } from './atlas'; +import type { Fixture, NodeSpec } from './fixtures'; +import type { LightningElement } from '@plextv/react-lightning'; + +// Minimal element surface LightningManager reads. Mirrors the fake used in the +// unit tests, plus text fields so the grow-only remeasure path runs. +type Handler = (...args: unknown[]) => void; + +let nextId = 1; + +class BenchElement { + public id = nextId++; + public parent: BenchElement | null = null; + public children: BenchElement[] = []; + public isTextElement = false; + public isImageElement = false; + public text?: string; + public style: Record = {}; + public props: { style: Record } = { style: {} }; + public rawProps: { style: Record } = { style: {} }; + public node: Record = {}; + public hasLayout = false; + + private _handlers = new Map>(); + + public on(event: string, handler: Handler): () => void { + let set = this._handlers.get(event); + if (!set) { + set = new Set(); + this._handlers.set(event, set); + } + set.add(handler); + const handlers = set; + return () => handlers.delete(handler); + } + + public emit(event: string, ...args: unknown[]): void { + const set = this._handlers.get(event); + if (!set) return; + for (const handler of [...set]) handler(...args); + } + + public withholdPaintUntilLayout(): void {} + + public setNodeProp(key: string, value: unknown): boolean { + if (this.node[key] === value) return false; + this.node[key] = value; + return true; + } + + public emitLayoutEvent(): void { + this.hasLayout = true; + } +} + +export type Metrics = { + nodeCount: number; + layoutPasses: number; + settles: number; + maxPassesToSettle: number; + reflows: number; + textRemeasures: number; + ms: number; +}; + +const asLng = (el: BenchElement) => el as unknown as LightningElement; + +function makeElement(spec: NodeSpec): BenchElement { + const el = new BenchElement(); + const style = { ...(spec.style ?? {}) } as Record; + if (spec.text != null) { + el.isTextElement = true; + el.text = spec.text; + style.fontFamily = style.fontFamily ?? BENCH_FONT; + } + el.style = style; + el.props.style = style; + el.rawProps.style = style; + return el; +} + +// Build a cell subtree, mark its root as an independent flex root (as +// VirtualListCell does). Returns every element created, leaves last, so the +// caller can tear it down in reverse. +function mountCell(manager: LightningManager, spec: NodeSpec, crossSize?: number): BenchElement[] { + const created: BenchElement[] = []; + + const build = (node: NodeSpec, parent: BenchElement | null): BenchElement => { + const el = makeElement(node); + manager.trackElement(asLng(el)); + manager.applyStyle(el.id, el.style, true); + created.push(el); + + if (parent) { + el.parent = parent; + parent.children.push(el); + parent.emit('childAdded', el, parent.children.length - 1); + } + + for (const child of node.children ?? []) { + build(child, el); + } + return el; + }; + + const root = build(spec, null); + if (crossSize != null) { + root.style.w = crossSize; + manager.applyStyle(root.id, { w: crossSize }, true); + } + manager.markFlexRoot(asLng(root)); + + return created; +} + +function destroyCell(manager: LightningManager, els: BenchElement[]): void { + for (let i = els.length - 1; i >= 0; i--) { + els[i]?.emit('destroy'); + } +} + +async function drain(): Promise { + for (let i = 0; i < 4; i++) await Promise.resolve(); +} + +export async function runFixture(fixture: Fixture): Promise { + nextId = 1; + const manager = new LightningManager(); + await manager.init({ + fonts: [{ fontFamily: BENCH_FONT, atlasDataUrl: benchAtlasUrl }], + }); + + const yoga = (manager as unknown as { _yogaManager: YogaManager })._yogaManager; + + // Metrics wiring. + let layoutPasses = 0; + let settles = 0; + let passesSinceAction = 0; + let maxPassesToSettle = 0; + let reflows = 0; + const lastSize = new Map(); + + yoga.on('render', (buffer: ArrayBuffer) => { + layoutPasses++; + passesSinceAction++; + const view = new DataView(buffer); + for (let o = 0; o + 20 <= buffer.byteLength; o += 20) { + const id = view.getUint32(o, true); + const w = view.getInt32(o + 12, true); + const h = view.getInt32(o + 16, true); + const key = `${w}x${h}`; + const prev = lastSize.get(id); + if (prev !== undefined && prev !== key) reflows++; + lastSize.set(id, key); + } + }); + + yoga.on('settled', () => { + settles++; + if (passesSinceAction > maxPassesToSettle) maxPassesToSettle = passesSinceAction; + passesSinceAction = 0; + }); + + // Count text remeasures beyond each node's first. + let textRemeasures = 0; + const seenTextMeasure = new Set(); + const origSetTextMeasure = yoga.setTextMeasure.bind(yoga); + (yoga as unknown as { setTextMeasure: typeof yoga.setTextMeasure }).setTextMeasure = (( + id: number, + ...rest: unknown[] + ) => { + if (seenTextMeasure.has(id)) textRemeasures++; + else seenTextMeasure.add(id); + // @ts-expect-error passthrough + return origSetTextMeasure(id, ...rest); + }) as typeof yoga.setTextMeasure; + + const start = performance.now(); + + // Mount the initial window. + const mounted: BenchElement[][] = []; + for (let i = 0; i < fixture.window; i++) { + passesSinceAction = 0; + mounted.push(mountCell(manager, fixture.cell(i), fixture.cellCrossSize)); + await drain(); + } + + // Scroll: recycle top -> bottom. + for (let step = 0; step < fixture.scrollSteps; step++) { + const top = mounted.shift(); + if (top) destroyCell(manager, top); + passesSinceAction = 0; + mounted.push(mountCell(manager, fixture.cell(fixture.window + step), fixture.cellCrossSize)); + await drain(); + } + + const ms = performance.now() - start; + + return { + nodeCount: nextId - 1, + layoutPasses, + settles, + maxPassesToSettle, + reflows, + textRemeasures, + ms: Math.round(ms * 10) / 10, + }; +} diff --git a/packages/plugin-flexbox/bench/run.ts b/packages/plugin-flexbox/bench/run.ts new file mode 100644 index 00000000..1f40fca2 --- /dev/null +++ b/packages/plugin-flexbox/bench/run.ts @@ -0,0 +1,114 @@ +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { fixtures } from './fixtures'; +import { type Metrics, runFixture } from './harness'; + +// Structural metrics are counts (layout passes, remeasures, ...) — algorithmic, +// so they don't vary by machine. `ms` is wall time and does, so it's reported +// but never gated. The per-engineer dev loop is: `save` a local baseline on +// your branch point, then compare the delta after your change. +const GATED: (keyof Metrics)[] = [ + 'nodeCount', + 'layoutPasses', + 'settles', + 'maxPassesToSettle', + 'reflows', + 'textRemeasures', +]; +const REPORTED: (keyof Metrics)[] = [...GATED, 'ms']; + +const here = dirname(fileURLToPath(import.meta.url)); +const referencePath = join(here, 'baseline.json'); // committed, machine-independent counts +const localPath = join(here, '.baseline.local.json'); // gitignored, per-engineer snapshot + +type Baseline = Record>; + +const read = (p: string): Baseline => { + try { + return JSON.parse(readFileSync(p, 'utf8')) as Baseline; + } catch { + return {}; + } +}; + +export async function collect(): Promise> { + const out: Record = {}; + for (const fixture of fixtures) { + out[fixture.name] = await runFixture(fixture); + } + return out; +} + +// Gated drift only (used by the CI reference gate). +export function diff( + current: Record, + baseline: Baseline, +): { name: string; metric: string; from: number; to: number }[] { + const drift: { name: string; metric: string; from: number; to: number }[] = []; + for (const [name, metrics] of Object.entries(current)) { + const base = baseline[name]; + if (!base) { + drift.push({ name, metric: '(new fixture)', from: NaN, to: NaN }); + continue; + } + for (const key of GATED) { + if (base[key] !== metrics[key]) { + drift.push({ name, metric: key, from: base[key] as number, to: metrics[key] }); + } + } + } + return drift; +} + +function writeBaseline(path: string, current: Record, keys: (keyof Metrics)[]): void { + const out: Baseline = {}; + for (const [name, m] of Object.entries(current)) { + out[name] = Object.fromEntries(keys.map((k) => [k, m[k]])) as Partial; + } + writeFileSync(path, `${JSON.stringify(out, null, 2)}\n`); +} + +function fmtDelta(from: number | undefined, to: number): string { + if (from == null || Number.isNaN(from)) return ''; + const d = to - from; + if (d === 0) return ' (=)'; + return ` (${d > 0 ? '+' : ''}${Math.round(d * 10) / 10})`; +} + +function report(current: Record, against?: Baseline): void { + for (const [name, m] of Object.entries(current)) { + const base = against?.[name]; + const cols = REPORTED.map((k) => `${k}=${m[k]}${base ? fmtDelta(base[k] as number, m[k] as number) : ''}`); + console.log(`${name.padEnd(8)} ${cols.join(' ')}`); + } +} + +export async function main(): Promise { + const mode = process.argv[2]; + const current = await collect(); + + if (mode === 'save') { + writeBaseline(localPath, current, REPORTED); + report(current); + console.log(`\nlocal baseline saved to ${localPath}\nMake your change, then run \`bench\` to see the delta.`); + return; + } + + if (mode === '--update') { + writeBaseline(referencePath, current, GATED); + report(current); + console.log('\ncommitted reference baseline updated.'); + return; + } + + // Default: compare vs the local snapshot (the delta is the signal). + if (existsSync(localPath)) { + console.log('delta vs your local baseline:\n'); + report(current, read(localPath)); + return; + } + + report(current); + console.log('\nNo local baseline yet. Run `bench save` at your branch point, then `bench` after your change.'); +} diff --git a/packages/plugin-flexbox/package.json b/packages/plugin-flexbox/package.json index ce56ff1b..aa926a89 100644 --- a/packages/plugin-flexbox/package.json +++ b/packages/plugin-flexbox/package.json @@ -46,7 +46,10 @@ "build:copy-dts": "copyfiles -f src/types/jsx.d.ts dist/types/types", "clean": "del ./dist", "check:types": "tsc --noEmit", - "test:unit": "vitest run --passWithNoTests" + "test:unit": "vitest run --passWithNoTests", + "bench": "tsx bench/cli.ts", + "bench:update": "tsx bench/cli.ts --update", + "bench:save": "tsx bench/cli.ts save" }, "dependencies": { "tseep": "catalog:", diff --git a/packages/plugin-flexbox/src/LightningManager.test.ts b/packages/plugin-flexbox/src/LightningManager.test.ts new file mode 100644 index 00000000..2bf25f30 --- /dev/null +++ b/packages/plugin-flexbox/src/LightningManager.test.ts @@ -0,0 +1,295 @@ +import { describe, expect, it } from 'vitest'; + +import type { LightningElement } from '@plextv/react-lightning'; +import { LightningManager } from './LightningManager'; +import type { YogaManager } from './YogaManager'; + +type Handler = (...args: unknown[]) => void; + +// Just enough element surface for trackElement's listeners and markFlexRoot. +class FakeElement { + public id: number; + public parent: FakeElement | null = null; + public children: FakeElement[] = []; + public isTextElement = false; + public isImageElement = false; + public props: { style: Record } = { style: {} }; + public rawProps: { style: Record } = { style: {} }; + public style: Record = {}; + public node: Record = {}; + public hasLayout = false; + + private _handlers = new Map>(); + + constructor(id: number) { + this.id = id; + } + + public on(event: string, handler: Handler): () => void { + let set = this._handlers.get(event); + + if (!set) { + set = new Set(); + this._handlers.set(event, set); + } + + set.add(handler); + + return () => set.delete(handler); + } + + public emit(event: string, ...args: unknown[]): void { + for (const handler of this._handlers.get(event) ?? []) { + handler(...args); + } + } + + public withholdPaintUntilLayout(): void {} + + public setNodeProp(key: string, value: unknown): boolean { + if (this.node[key] === value) { + return false; + } + + this.node[key] = value; + + return true; + } + + public emitLayoutEvent(): void { + this.hasLayout = true; + } +} + +const asElement = (fake: FakeElement) => fake as unknown as LightningElement; + +type ManagerNodeLike = { node: { getComputedLeft(): number } }; + +function getYoga(manager: LightningManager): YogaManager { + return (manager as unknown as { _yogaManager: YogaManager })._yogaManager; +} + +// Ground truth from the native yoga nodes: lay the row out and read each +// child's computed x. Child order is the only thing that decides it here. +async function computedOrder(manager: LightningManager, ids: number[]): Promise { + const yoga = getYoga(manager); + + await new Promise((resolve) => { + const handler = () => { + yoga.off('render', handler); + resolve(); + }; + + yoga.on('render', handler); + yoga.queueRender(1, true); + }); + + const elementMap = (yoga as unknown as { _elementMap: Map })._elementMap; + + return [...ids] + .map((id) => ({ id, x: (elementMap.get(id) as ManagerNodeLike).node.getComputedLeft() })) + .sort((a, b) => a.x - b.x) + .map((entry) => entry.id); +} + +type ManagerNodeLikeSize = { node: { getComputedLeft(): number; getComputedWidth(): number } }; + +// Renders once and reads a single node's computed left/width, for tests +// that only care about one element instead of a sibling ordering. +async function computedBox( + manager: LightningManager, + id: number, +): Promise<{ left: number; width: number }> { + const yoga = getYoga(manager); + + await new Promise((resolve) => { + const handler = () => { + yoga.off('render', handler); + resolve(); + }; + + yoga.on('render', handler); + yoga.queueRender(1, true); + }); + + const elementMap = (yoga as unknown as { _elementMap: Map }) + ._elementMap; + const node = elementMap.get(id)?.node; + + return { left: node?.getComputedLeft() ?? Number.NaN, width: node?.getComputedWidth() ?? Number.NaN }; +} + +const CHILD_IDS = [2, 3, 4]; + +async function setup() { + const manager = new LightningManager(); + await manager.init(); + + const parent = new FakeElement(1); + + parent.style = { display: 'flex', flexDirection: 'row', w: 100, h: 10 }; + manager.trackElement(asElement(parent)); + manager.markFlexRoot(asElement(parent)); + manager.applyStyle(parent.id, parent.style, true); + + for (const [index, id] of CHILD_IDS.entries()) { + const child = new FakeElement(id); + + child.parent = parent; + child.style = { w: 10, h: 10 }; + parent.children.push(child); + manager.trackElement(asElement(child)); + manager.applyStyle(child.id, child.style, true); + parent.emit('childAdded', child, index); + } + + return { manager, parent }; +} + +function moveChild(parent: FakeElement, fromIndex: number, toIndex: number): void { + const [child] = parent.children.splice(fromIndex, 1); + + parent.children.splice(toIndex, 0, child as FakeElement); + parent.emit('childMoved', child, fromIndex, toIndex); +} + +describe('LightningManager childMoved', () => { + it('lays children out in the original order before any move', async () => { + const { manager } = await setup(); + + expect(await computedOrder(manager, CHILD_IDS)).toEqual([2, 3, 4]); + }); + + it('moves the first yoga child to the end (append fast path)', async () => { + const { manager, parent } = await setup(); + + moveChild(parent, 0, parent.children.length - 1); + + expect(await computedOrder(manager, CHILD_IDS)).toEqual([3, 4, 2]); + }); + + it('moves the last yoga child to the front', async () => { + const { manager, parent } = await setup(); + + moveChild(parent, 2, 0); + + expect(await computedOrder(manager, CHILD_IDS)).toEqual([4, 2, 3]); + }); + + it('moves a middle yoga child forward by one', async () => { + const { manager, parent } = await setup(); + + moveChild(parent, 1, 2); + + expect(await computedOrder(manager, CHILD_IDS)).toEqual([2, 4, 3]); + }); +}); + +describe('LightningManager applyStyle resetMissing', () => { + it('resets gap to its yoga default (0) when a re-applied style drops it', async () => { + const manager = new LightningManager(); + await manager.init(); + + const parent = new FakeElement(1); + + parent.style = { display: 'flex', flexDirection: 'row', gap: 16, w: 100, h: 10 }; + manager.trackElement(asElement(parent)); + manager.markFlexRoot(asElement(parent)); + manager.applyStyle(parent.id, parent.style, true, true); + + for (const [index, id] of [2, 3].entries()) { + const child = new FakeElement(id); + + child.parent = parent; + child.style = { w: 10, h: 10 }; + parent.children.push(child); + manager.trackElement(asElement(child)); + manager.applyStyle(child.id, child.style, true); + parent.emit('childAdded', child, index); + } + + expect((await computedBox(manager, 3)).left).toBe(26); // 10 (first child) + 16 gap + + // Re-apply without `gap`, as if a conditional style flipped off. + manager.applyStyle( + parent.id, + { display: 'flex', flexDirection: 'row', w: 100, h: 10 }, + true, + true, + ); + + expect((await computedBox(manager, 3)).left).toBe(10); // gap back to yoga's default + }); + + it('resets alignItems to its yoga default (stretch) when a re-applied style drops it', async () => { + const manager = new LightningManager(); + await manager.init(); + + const parent = new FakeElement(1); + + parent.style = { + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', + w: 300, + h: 200, + }; + manager.trackElement(asElement(parent)); + manager.markFlexRoot(asElement(parent)); + manager.applyStyle(parent.id, parent.style, true, true); + + const child = new FakeElement(2); + + child.parent = parent; + child.style = { h: 10 }; // no explicit width, follows alignItems on the cross axis + parent.children.push(child); + manager.trackElement(asElement(child)); + manager.applyStyle(child.id, child.style, true); + parent.emit('childAdded', child, 0); + + expect((await computedBox(manager, 2)).width).toBe(0); // flex-start: shrinks to content + + // Re-apply without `alignItems`, falls back to yoga's stretch default. + manager.applyStyle( + parent.id, + { display: 'flex', flexDirection: 'column', w: 300, h: 200 }, + true, + true, + ); + + expect((await computedBox(manager, 2)).width).toBe(300); // stretch: fills the parent + }); + + it('keeps a still-present prop untouched (no spurious reset)', async () => { + const manager = new LightningManager(); + await manager.init(); + + const parent = new FakeElement(1); + + parent.style = { display: 'flex', flexDirection: 'row', gap: 16, w: 100, h: 10 }; + manager.trackElement(asElement(parent)); + manager.markFlexRoot(asElement(parent)); + manager.applyStyle(parent.id, parent.style, true, true); + + for (const [index, id] of [2, 3].entries()) { + const child = new FakeElement(id); + + child.parent = parent; + child.style = { w: 10, h: 10 }; + parent.children.push(child); + manager.trackElement(asElement(child)); + manager.applyStyle(child.id, child.style, true); + parent.emit('childAdded', child, index); + } + + // Re-apply with the same gap value present, should not be reset. + manager.applyStyle( + parent.id, + { display: 'flex', flexDirection: 'row', gap: 16, w: 100, h: 10 }, + true, + true, + ); + + expect((await computedBox(manager, 3)).left).toBe(26); + }); +}); diff --git a/packages/plugin-flexbox/src/LightningManager.ts b/packages/plugin-flexbox/src/LightningManager.ts index 2b0d2995..62ccfb66 100644 --- a/packages/plugin-flexbox/src/LightningManager.ts +++ b/packages/plugin-flexbox/src/LightningManager.ts @@ -2,15 +2,22 @@ import type { LightningElement, LightningElementStyle, LightningTextElement, + LightningTextElementStyle, RendererNode, TextRendererNode, } from '@plextv/react-lightning'; +import type { TextMeasureProps } from './text/layoutText'; import type { YogaOptions } from './types/YogaOptions'; import loadYoga from './yoga'; import type { YogaManager } from './YogaManager'; import type { Workerized } from './YogaManagerWorker'; +// Sub-glyph slack added to a measured text node's rendered contain width so the +// renderer doesn't clip the final glyph(s) when our measurement lands a hair +// under its own. Far below one glyph, so it never affects wrapping. +const TEXT_CONTAIN_EPSILON = 2; + /** Lifecycle of Yoga nodes for Lightning elements. Main-thread only. */ export class LightningManager { private _elements = new Map(); @@ -20,13 +27,48 @@ export class LightningManager { private _yogaParents = new Map(); /** Per-parent attached-children count. Lets `_yogaIndexFor` skip the O(n) sibling walk on append-at-end. */ private _yogaChildCounts = new Map(); + /** Text elements measured by Yoga — their node w/h/contain come from layout, not the async texture. */ + private _measuredText = new Set(); + /** + * Per measured-text element, the widest parent node width it has been measured + * against. Yoga caches measure results and won't re-call the measure func when + * a text node's container resolves to a wider width after an early narrow + * measure. When the container grows past this we re-dirty the text so it + * re-measures at the real width (see the grow-only re-dirty in `_applyUpdates`). + */ + private _textContextWidth = new Map(); private _yogaManager: YogaManager | Workerized | undefined; + /** + * Font families we have metrics for and can measure. Only text in one of + * these is measured by Yoga; everything else (e.g. the canvas `plex-icons` + * glyph font) falls back to the renderer's own sizing. + */ + private _measurableFonts = new Set(); + public async init(yogaOptions?: YogaOptions): Promise { + this._measurableFonts = new Set((yogaOptions?.fonts ?? []).map((font) => font.fontFamily)); this._yogaManager = await loadYoga(yogaOptions); this._yogaManager.on('render', this._applyUpdates); } + /** + * Subscribe to Yoga's `settled` event (layout converged to a fixpoint). + * Main-thread only — the worker proxy never emits it, so the callback + * simply never fires there and callers fall back to their timers. + */ + public onSettled(callback: () => void): () => void { + const manager = this._yogaManager; + + if (!manager) { + return () => {}; + } + + manager.on('settled', callback); + + return () => manager.off('settled', callback); + } + /** * Detaches the element's subtree from yoga (and excludes future * descendants). A nested {@link markFlexRoot} re-enables flex below it. @@ -47,6 +89,13 @@ export class LightningManager { } } + // The boundary's descendants no longer get a layout (until a nested flex + // root re-opts them in), so any that were withheld waiting for a first + // layout would never be revealed. Release them now. + for (let i = 0; i < element.children.length; i++) { + this._releaseWithheldSubtree(element.children[i]); + } + // Tree shape changed — re-layout any flex roots that contain it. this._yogaManager.queueRender(element.id); } @@ -82,6 +131,10 @@ export class LightningManager { this._reattachChildren(element); + // A definite-sized root paints at its origin until its first layout + // resolves — withhold paint until then. No-op for 0x0 roots. + element.withholdPaintUntilLayout(); + // First layout pass — without this the root sits at 0,0 until // something else calls applyStyle. this._yogaManager.queueRender(element.id); @@ -192,6 +245,10 @@ export class LightningManager { this._yogaManager.addChildNode(parent.id, child.id, yogaIndex); this._setYogaParent(child.id, parent.id); + // Hide a definite-sized node until its first layout positions it, so it + // doesn't paint at its pre-layout origin while the (async) layout is in + // flight. No-op for 0x0 nodes — the common case. + child.withholdPaintUntilLayout(); yogaIndex++; if (!this._boundaries.has(child.id)) { @@ -200,6 +257,58 @@ export class LightningManager { } } + /** Recursively reveal any withheld nodes in a subtree that has been detached + * from flex layout (and so would never receive the first layout that reveals + * them). Stops at nested flex roots, whose subtrees stay in layout. */ + private _releaseWithheldSubtree(element: LightningElement | undefined): void { + if (!element || this._flexRoots.has(element.id)) { + return; + } + + element.releaseWithheldPaint(); + + for (let i = 0; i < element.children.length; i++) { + this._releaseWithheldSubtree(element.children[i]); + } + } + + /** + * Push a text element's content + font props to Yoga so it can measure the + * text during layout. Only text in a font we have metrics for is measured; + * anything else (no family, or a non-measurable font like the canvas + * `plex-icons` glyph font) is left to the renderer's own sizing. + */ + private _syncTextMeasure(element: LightningElement): void { + if (!this._yogaManager) { + return; + } + + const style = (element.style ?? {}) as Partial; + const fontFamily = style.fontFamily; + + if (!fontFamily || !this._measurableFonts.has(fontFamily)) { + if (this._measuredText.delete(element.id)) { + this._textContextWidth.delete(element.id); + this._yogaManager.clearTextMeasure(element.id); + } + + return; + } + + this._measuredText.add(element.id); + this._yogaManager.setTextMeasure(element.id, fontFamily, { + text: (element as LightningTextElement).text ?? '', + fontSize: style.fontSize || 16, + letterSpacing: style.letterSpacing || 0, + // 0 / undefined lineHeight → natural (1× metrics); a value > 3 is px. + lineHeight: style.lineHeight || 1, + maxLines: style.maxLines || 0, + maxHeight: style.maxHeight || 0, + wordBreak: (style.wordBreak as TextMeasureProps['wordBreak']) || 'break-word', + overflowSuffix: style.overflowSuffix ?? '...', + }); + } + public trackElement(element: LightningElement): void { if (this._elements.has(element.id)) { console.warn(`Yoga node is already attached to element #${element.id}.`); @@ -214,6 +323,12 @@ export class LightningManager { this._elements.set(element.id, element); this._yogaManager.addNode(element.id); + // Set text measurement before any children mount, so the Yoga node is a + // leaf when its measure func is installed (Yoga requires that). + if (element.isTextElement) { + this._syncTextMeasure(element); + } + const disposers = [ element.on('destroy', () => { for (const dispose of disposers) { @@ -230,6 +345,8 @@ export class LightningManager { this._boundaries.delete(element.id); this._flexRoots.delete(element.id); this._yogaChildCounts.delete(element.id); + this._measuredText.delete(element.id); + this._textContextWidth.delete(element.id); // oxlint-disable-next-line typescript/no-non-null-assertion -- Guaranteed to exist. But avoiding the nullish operator for perf reasons this._yogaManager!.applyStyle(element.id, null, true); // oxlint-disable-next-line typescript/no-non-null-assertion -- Guaranteed to exist. See above @@ -250,6 +367,8 @@ export class LightningManager { // oxlint-disable-next-line typescript/no-non-null-assertion -- Guaranteed to exist. See above this._yogaManager!.addChildNode(element.id, child.id, yogaIndex); this._setYogaParent(child.id, element.id); + // See _reattachChildren — withhold paint until first layout. + child.withholdPaintUntilLayout(); this.applyStyle(element.id, element.style); // React mounts bottom-up: `child`'s descendants were inserted @@ -279,6 +398,28 @@ export class LightningManager { this._yogaManager!.queueRender(element.id); }), + // Same-parent reorder (React's keyed move). Reindex in place, keep the yoga node alive. + element.on('childMoved', (child, _fromIndex, toIndex) => { + if (this._yogaParents.get(child.id) !== element.id) { + // Not one of this parent's yoga children (boundary or flex root), nothing to reindex. + return; + } + + // Detach before computing the index: the append-at-end fast path in + // _yogaIndexFor reads the cached count, which must not include the child. + // oxlint-disable-next-line typescript/no-non-null-assertion -- Guaranteed to exist. See above + this._yogaManager!.detachChildNode(element.id, child.id); + this._clearYogaParent(child.id, element.id); + + const yogaIndex = this._yogaIndexFor(element, toIndex); + + // oxlint-disable-next-line typescript/no-non-null-assertion -- Guaranteed to exist. See above + this._yogaManager!.addChildNode(element.id, child.id, yogaIndex); + this._setYogaParent(child.id, element.id); + // oxlint-disable-next-line typescript/no-non-null-assertion -- Guaranteed to exist. See above + this._yogaManager!.queueRender(element.id); + }), + element.on('inViewport', () => { if (!element.isTextElement && !element.isImageElement) { this.applyStyle(element.id, element.props.style); @@ -287,6 +428,11 @@ export class LightningManager { element.on('stylesChanged', () => { this.applyStyle(element.id, element.props.style); + + // Font/size/maxLines changes affect measurement. + if (element.isTextElement) { + this._syncTextMeasure(element); + } }), element.on( @@ -296,6 +442,22 @@ export class LightningManager { event: { type: string; dimensions: { w: number; h: number } }, ) => { if (element.isTextElement) { + // Static text (set once at mount via the initial-props path, not the + // `text` setter) never fires `textChanged`, so `_syncTextMeasure` + // only ran at trackElement before the content existed and the node + // was never measured. The renderer's texture-loaded event is the + // first point where `node.text` is reliably populated — sync now so + // such text gets a measure func and wraps like dynamic text does. + if (!this._measuredText.has(element.id)) { + this._syncTextMeasure(element); + } + + // When Yoga measures the text itself, it owns the node's size — + // pushing the async texture size back would fight the measure func. + if (this._measuredText.has(element.id)) { + return; + } + this.applyStyle(element.id, { w: event.dimensions.w, h: event.dimensions.h, @@ -309,12 +471,28 @@ export class LightningManager { }, ), ]; + + // Re-measure when text content changes. `propsChanged` covers the setProps + // path; `textChanged` covers value changes that bypass it — recycled nodes + // (commitTextUpdate) and folded fragment text (recomputeChildText) — which + // is the common case for reused preview/hero nodes. + if (element.isTextElement) { + disposers.push( + element.on('propsChanged', () => { + this._syncTextMeasure(element); + }), + element.on('textChanged', () => { + this._syncTextMeasure(element); + }), + ); + } } public applyStyle( elementId: number, style?: Partial | null, skipRender = false, + resetMissing = false, ): void { if (!this._elements.has(elementId)) { return; @@ -322,7 +500,7 @@ export class LightningManager { if (style) { // oxlint-disable-next-line typescript/no-non-null-assertion -- Guaranteed to exist. See above - this._yogaManager!.applyStyle(elementId, style, skipRender); + this._yogaManager!.applyStyle(elementId, style, skipRender, resetMissing); } } @@ -333,14 +511,14 @@ export class LightningManager { const length = buffer.byteLength; let offset = 0; - // See YogaManager.ts for the structure of the updates (12 bytes/entry) - while (offset + 12 <= length) { + // See YogaManager.ts for the structure of the updates (20 bytes/entry) + while (offset + 20 <= length) { const elementId = view.getUint32(offset, true); - const x = view.getInt16(offset + 4, true); - const y = view.getInt16(offset + 6, true); - const width = view.getUint16(offset + 8, true); - const height = view.getUint16(offset + 10, true); - offset += 12; + const x = view.getInt32(offset + 4, true); + const y = view.getInt32(offset + 8, true); + const width = view.getInt32(offset + 12, true); + const height = view.getInt32(offset + 16, true); + offset += 20; const el = this._elements.get(elementId); @@ -375,13 +553,37 @@ export class LightningManager { dirty = el.setNodeProp('y', y) || dirty; } - // Skip zero (causes layout issues) and text elements (Lightning sizes them). - if (width !== 0 && !isText) { - dirty = el.setNodeProp('w', width) || dirty; + // Normally text elements are sized by Lightning (async texture measure), + // so we skip them here. But when Yoga measures the text itself, its + // computed size IS the text box: apply it and pin contain:'width' so the + // renderer wraps to the same width and textAlign has a box to align in. + const isMeasuredText = isText && this._measuredText.has(elementId); + + if (width !== 0 && (!isText || isMeasuredText)) { + if (isMeasuredText) { + // `contain` lives on the text node, not the base node type — set it + // directly. Wrapping/textAlign only take effect with a contained width. + const textNode = el.node as TextRendererNode; + + if (textNode.contain !== 'width') { + textNode.contain = 'width'; + } + + // Pin the renderer's text box a hair wider than the Yoga-measured + // width. Our msdf measurement can land a sub-pixel under the + // renderer's own glyph layout; containing to the exact width would + // clip the final glyphs (e.g. "Sign Up" → "Sign…"). The epsilon is + // far below a glyph, so it never changes wrapping, and Yoga still + // positions siblings from its own (un-padded) computed width. + dirty = el.setNodeProp('w', width + TEXT_CONTAIN_EPSILON) || dirty; + } else { + dirty = el.setNodeProp('w', width) || dirty; + } + resize = true; } - if (height !== 0 && !isText) { + if (height !== 0 && (!isText || isMeasuredText)) { dirty = el.setNodeProp('h', height) || dirty; resize = true; } @@ -394,5 +596,39 @@ export class LightningManager { el.emitLayoutEvent(); } } + + // Yoga caches text measurements and won't re-run the measure func when a + // container resolves to its real (wider) width after an early too-narrow + // measure — leaving text stuck narrow (the classic collapsed-then-expanded + // hero title). After each layout, if a measured text node's container has + // GROWN past the width it was last measured against, re-dirty it so Yoga + // re-measures at the now-available width. + // + // Grow-only is deliberate. The container width is often *derived* from the + // text itself (a shrink-to-content wrapper) or from a sibling whose size + // toggles (e.g. ClearLogo's logo image vs. its text fallback). Re-measuring + // on every change — including shrink — feeds that derived width back into + // the measure and oscillates (the same title flip-flopping between e.g. 686 + // and 462). Reacting only to growth converges (the recorded width climbs + // monotonically until it matches the settled container) and biases toward + // the widest the container ever offered, which never clips. New text on a + // recycled node is handled separately by the `textChanged` → setTextMeasure + // measure-func reinstall, so a narrower reuse still re-measures correctly. + if (this._measuredText.size > 0) { + for (const textId of this._measuredText) { + const textEl = this._elements.get(textId); + + if (!textEl) { + continue; + } + + const contextWidth = textEl.parent?.node.w ?? 0; + + if (contextWidth > (this._textContextWidth.get(textId) ?? 0)) { + this._textContextWidth.set(textId, contextWidth); + this._syncTextMeasure(textEl); + } + } + } }; } diff --git a/packages/plugin-flexbox/src/YogaManager.layout.test.ts b/packages/plugin-flexbox/src/YogaManager.layout.test.ts new file mode 100644 index 00000000..838368b0 --- /dev/null +++ b/packages/plugin-flexbox/src/YogaManager.layout.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { YogaManager } from './YogaManager'; + +// A minimal independent root with one child so a layout pass has real work. +async function setup() { + const manager = new YogaManager(); + await manager.init(); + + manager.addNode(1); + manager.applyStyle(1, { w: 300, h: 200, display: 'flex' }, true); + manager.addIndependentRoot(1); + + manager.addNode(2); + manager.applyStyle(2, { w: 100, h: 50 }, true); + manager.addChildNode(1, 2); + + return manager; +} + +describe('YogaManager.flushLayout', () => { + it('runs layout synchronously (render fires before the call returns)', async () => { + const manager = await setup(); + let rendered = false; + manager.on('render', () => { + rendered = true; + }); + + manager.flushLayout(); + + expect(rendered).toBe(true); + }); + + it('emits settled once, after the last pass, when nothing re-dirties', async () => { + const manager = await setup(); + const order: string[] = []; + manager.on('render', () => order.push('render')); + manager.on('settled', () => order.push('settled')); + + manager.flushLayout(); + + expect(order.filter((e) => e === 'settled')).toHaveLength(1); + expect(order[order.length - 1]).toBe('settled'); + }); + + it('loops to a fixpoint: keeps laying out while a listener re-dirties', async () => { + const manager = await setup(); + let renders = 0; + let settled = 0; + manager.on('settled', () => settled++); + // Simulate the grow-only text re-dirty: request another pass twice, then stop. + manager.on('render', () => { + renders++; + if (renders < 3) { + manager.queueRender(1); + } + }); + + manager.flushLayout(); + + expect(renders).toBe(3); + expect(settled).toBe(1); + }); + + it('stops at the pass cap and warns if layout never settles', async () => { + const manager = await setup(); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + let settled = 0; + manager.on('settled', () => settled++); + // Never converges: always asks for another pass. + manager.on('render', () => manager.queueRender(1)); + + manager.flushLayout(); + + expect(warn).toHaveBeenCalledOnce(); + expect(settled).toBe(1); + warn.mockRestore(); + }); +}); + +describe('YogaManager async render path', () => { + it('emits settled after the microtask flush converges', async () => { + const manager = await setup(); + const settled = vi.fn(); + manager.on('settled', settled); + + manager.queueRender(1); + expect(settled).not.toHaveBeenCalled(); // deferred to a microtask + + await Promise.resolve(); + + expect(settled).toHaveBeenCalledOnce(); + }); + + it('keeps one layout pass per microtask (async timing unchanged)', async () => { + const manager = await setup(); + let renders = 0; + manager.on('render', () => { + renders++; + if (renders < 2) { + manager.queueRender(1); + } + }); + + manager.queueRender(1); + await Promise.resolve(); + expect(renders).toBe(1); // first pass only; re-dirty scheduled a second microtask + + await Promise.resolve(); + expect(renders).toBe(2); + }); +}); diff --git a/packages/plugin-flexbox/src/YogaManager.spec.ts b/packages/plugin-flexbox/src/YogaManager.spec.ts index b0b4a548..ab6bcd0c 100644 --- a/packages/plugin-flexbox/src/YogaManager.spec.ts +++ b/packages/plugin-flexbox/src/YogaManager.spec.ts @@ -20,6 +20,8 @@ const mockNode = { getComputedWidth: vi.fn(), getComputedHeight: vi.fn(), getMaxWidth: vi.fn(), + // Unanchored edge (unit UNDEFINED), matching a node with no right/bottom set. + getPosition: vi.fn(() => ({ unit: 0, value: undefined })), getParent: vi.fn(), markLayoutSeen: vi.fn(), }; @@ -33,6 +35,7 @@ const mockConfig = { const mockYogaOptions = { errata: 'none', expandToAutoFlexBasis: false, + fonts: [], processHiddenNodes: false, useWebDefaults: false, useWebWorker: false, @@ -199,6 +202,42 @@ describe('YogaManager', () => { expect(mockNode.free).toHaveBeenCalled(); }); + it('should detach the node from its yoga parent before freeing', () => { + const parentId = 1; + const childId = 2; + + yogaManager.addNode(parentId); + yogaManager.addNode(childId); + yogaManager.addChildNode(parentId, childId, 0); + + yogaManager.removeNode(childId); + + // Splicing only the ManagerNode children array leaves the freed child in + // the parent's yoga child list, so a shrink-fit parent keeps laying it + // out and never shrinks back. Detach from the yoga parent too. + expect(mockNode.removeChild).toHaveBeenCalledWith(mockNode); + }); + + it('should not detach from a parent that was already freed', () => { + const parentId = 1; + const childId = 2; + + yogaManager.addNode(parentId); + yogaManager.addNode(childId); + yogaManager.addChildNode(parentId, childId, 0); + + // React tears a subtree down root-first: the parent's yoga node is + // freed (via childRemoved) before the child's removeNode runs. Removing + // the child must not call removeChild on the parent's freed node, which + // is a use-after-free in yoga's wasm heap. + yogaManager.removeNode(parentId); + mockNode.removeChild.mockClear(); + + yogaManager.removeNode(childId); + + expect(mockNode.removeChild).not.toHaveBeenCalled(); + }); + it('should handle removing non-existent node', () => { yogaManager.removeNode(999); expect(mockNode.free).not.toHaveBeenCalled(); @@ -342,10 +381,10 @@ describe('YogaManager', () => { // Check each node's data for (let i = 0; i < numNodes; i++) { expect(dataView.readUint32()).toBe(i); // Node ID - expect(dataView.readInt16()).toBe(10); // x - expect(dataView.readInt16()).toBe(20); // y - expect(dataView.readInt16()).toBe(100); // width - expect(dataView.readInt16()).toBe(50); // height + expect(dataView.readInt32()).toBe(10); // x + expect(dataView.readInt32()).toBe(20); // y + expect(dataView.readInt32()).toBe(100); // width + expect(dataView.readInt32()).toBe(50); // height } resolve(); @@ -467,6 +506,7 @@ describe('YogaManager', () => { props: {}, }, style, + false, ); }); @@ -523,6 +563,23 @@ describe('YogaManager', () => { const { default: applyReactPropsToYoga } = await import('./util/applyReactPropsToYoga'); expect(applyReactPropsToYoga).toHaveBeenCalledTimes(2); }); + + it('forwards per-element resets from the batched applyStyles path', () => { + const styles = { + 123: { w: 100 }, + 456: { w: 200 }, + }; + + yogaManager.addNode(123); + yogaManager.addNode(456); + + const spy = vi.spyOn(yogaManager, 'applyStyle'); + + yogaManager.applyStyles(styles, true, { 123: 1 }); + + expect(spy).toHaveBeenCalledWith(123, styles[123], true, true); + expect(spy).toHaveBeenCalledWith(456, styles[456], true, false); + }); }); describe('event emitter', () => { diff --git a/packages/plugin-flexbox/src/YogaManager.ts b/packages/plugin-flexbox/src/YogaManager.ts index 794ab3ad..fa6dcb78 100644 --- a/packages/plugin-flexbox/src/YogaManager.ts +++ b/packages/plugin-flexbox/src/YogaManager.ts @@ -3,9 +3,15 @@ import { type Config, loadYoga, type Yoga } from 'yoga-layout/load'; import type { LightningElementStyle, Rect } from '@plextv/react-lightning'; +import { FontMetricsStore } from './text/FontMetricsStore'; +import { layoutText, type TextMeasureProps } from './text/layoutText'; import type { ManagerNode } from './types/ManagerNode'; import type { YogaOptions } from './types/YogaOptions'; import applyReactPropsToYoga, { applyFlexPropToYoga } from './util/applyReactPropsToYoga'; +import { + resolveHorizontalTranslate, + resolveVerticalTranslate, +} from './util/resolveTranslateInset'; import { SimpleDataView } from './util/SimpleDataView'; export type BatchedUpdate = Record>; @@ -17,17 +23,23 @@ export type YogaManagerEvents = { // array are reserved for the number of elements being updated. The rest of the // array contains the updates for each element. The data structure is as follows: // uint32 - The element ID of the element being updated - // int16 - The x coordinate of the element - // int16 - The y coordinate of the element - // int16 - The width of the element - // int16 - The height of the element + // int32 - The x coordinate of the element + // int32 - The y coordinate of the element + // int32 - The width of the element + // int32 - The height of the element render: (updates: ArrayBuffer) => void; + // Fires once layout has converged (no pass produced a re-dirty). Deterministic + // replacement for the timer-based "has it settled yet" guesses downstream. + settled: () => void; }; // elementId + x + y + width + height, as per spec above -const APPROX_SIZEOF_UPDATE = 4 + 2 + 2 + 2 + 2; +const APPROX_SIZEOF_UPDATE = 4 + 4 + 4 + 4 + 4; // 10KB, should be enough for most updates. If it's bigger than this, we'll chunk the updates const MAX_SIZEOF_UPDATE = 1024 * 10; +// Grow-only text remeasure converges monotonically (1-3 passes in practice). +// This is a runaway backstop, not an expected limit. +const MAX_LAYOUT_PASSES = 10; export class YogaManager { private _elementMap: Map = new Map(); @@ -37,15 +49,22 @@ export class YogaManager { private _config?: Config; private _initialized = false; private _isRenderQueued = false; + private _isFlushing = false; + private _needsAnotherPass = false; private _yogaOptions: Required = { useWebDefaults: false, errata: 'none', processHiddenNodes: false, useWebWorker: false, expandToAutoFlexBasis: false, + fonts: [], }; private _eventEmitter: EventEmitter = new EventEmitter(); private _dataView: SimpleDataView; + private _fontStore = new FontMetricsStore(); + // Text leaves currently measured by Yoga, so we can re-dirty them when a + // font finishes loading. + private _textNodes: Set = new Set(); public on: EventEmitter['on'] = this._eventEmitter.on.bind(this._eventEmitter); public off: EventEmitter['off'] = this._eventEmitter.off.bind( @@ -89,6 +108,129 @@ export class YogaManager { } this._initialized = true; + + // Await the font metrics so the first layout measures text for real. An + // unloaded font measures 0x0 and the arrival re-measure reflows the whole + // tree while it is already visible. `load` never rejects (a failed fetch + // warns and leaves the font unmeasured), so this can't hang init. The + // re-dirty stays as a backstop for a font that resolves late anyway. + if (this._yogaOptions.fonts) { + await Promise.all( + this._yogaOptions.fonts.map((font) => + this._fontStore.load(font.fontFamily, font.atlasDataUrl).then(() => { + this._remeasureFontFamily(font.fontFamily); + }), + ), + ); + } + } + + /** + * Set (or refresh) synchronous text measurement for a node. Installs a Yoga + * measure function the first time so wrapping/sizing happen during layout. + */ + public setTextMeasure(elementId: number, fontFamily: string, props: TextMeasureProps): void { + const yogaNode = this._elementMap.get(elementId); + + if (!yogaNode) { + return; + } + + const isFirst = yogaNode.text === undefined; + yogaNode.text = { fontFamily, props }; + + if (isFirst) { + // A measured leaf can't have children. If any were added before this + // node became text (e.g. the font/family arrived after children + // mounted), detach them — text fragment children aren't layout nodes. + for (const child of yogaNode.children) { + yogaNode.node.removeChild(child.node); + child.parent = undefined; + } + yogaNode.children.length = 0; + + this._textNodes.add(elementId); + } + + // Clear any explicit width/height so the measure func is the sole source of + // this node's size. The renderer measures text asynchronously and pushes + // its texture dimensions back as an explicit `w`/`h` (see the + // `textureLoaded` handler) — if that ran before the node became measured + // text, Yoga sees a DEFINITE width and never calls the measure func, so the + // node keeps the renderer's (often container-clipped) size. Resetting to + // auto makes Yoga measure it. Done every call so a recycled node that + // briefly went through the texture path is corrected too. + yogaNode.node.setWidthAuto(); + yogaNode.node.setHeightAuto(); + + // (Re)install the measure func every time. Re-setting it busts Yoga's + // cached measurement, which `markDirty` alone does not reliably do for a + // recycled node whose available width is unchanged — so changed text never + // re-measured and kept its stale width. + yogaNode.node.setMeasureFunc((width, widthMode) => + this._measureText(elementId, width, widthMode), + ); + + yogaNode.node.markDirty(); + this.queueRender(elementId); + } + + /** Remove text measurement from a node (e.g. it's no longer a text leaf). */ + public clearTextMeasure(elementId: number): void { + const yogaNode = this._elementMap.get(elementId); + + this._textNodes.delete(elementId); + + if (yogaNode?.text !== undefined) { + yogaNode.text = undefined; + yogaNode.node.setMeasureFunc(null); + this.queueRender(elementId); + } + } + + // widthMode is Yoga's MeasureMode: 0 = Undefined (unconstrained), 1 = Exactly, + // 2 = AtMost. Only a bounded width should wrap the text. + private _measureText( + elementId: number, + availableWidth: number, + widthMode: number, + ): { width: number; height: number } { + const text = this._elementMap.get(elementId)?.text; + + if (text === undefined) { + return { width: 0, height: 0 }; + } + + const font = this._fontStore.get(text.fontFamily); + + if (font === undefined) { + // Font not loaded yet — measure empty; _remeasureFontFamily re-dirties + // this node once it arrives. + return { width: 0, height: 0 }; + } + + const maxWidth = + widthMode === 0 || !Number.isFinite(availableWidth) ? Infinity : availableWidth; + + return layoutText(font, text.props, maxWidth); + } + + private _remeasureFontFamily(fontFamily: string): void { + let dirtied = false; + + for (const elementId of this._textNodes) { + const yogaNode = this._elementMap.get(elementId); + + if (yogaNode?.text?.fontFamily === fontFamily) { + yogaNode.node.markDirty(); + dirtied = true; + } + } + + if (dirtied) { + // Force a relayout pass so the newly-measurable text resizes. + this.queueRender(0, true); + } } public addNode(elementId: number): ManagerNode { @@ -105,20 +247,33 @@ export class YogaManager { } public removeNode(elementId: number): void { + this._textNodes.delete(elementId); + const yogaNode = this._elementMap.get(elementId); if (yogaNode) { - yogaNode.node.free(); - - // Remove the node from its parent's children array + // Detach from the parent's yoga node before freeing. Splicing only the + // ManagerNode children array leaves the freed child in the parent's yoga + // child list, so the parent keeps laying it out and a shrink-fit parent + // never shrinks back. if (yogaNode.parent) { const index = yogaNode.parent.children.indexOf(yogaNode); if (index !== -1) { yogaNode.parent.children.splice(index, 1); } + + // Only detach while the parent is still alive. React tears subtrees + // down root-first, so a descendant's parent can already be freed by + // the time we get here; removeChild on a freed node corrupts yoga's + // heap (surfaces as a re-mounted subtree that never lays out). + if (this._elementMap.has(yogaNode.parent.id)) { + yogaNode.parent.node.removeChild(yogaNode.node); + } } + yogaNode.node.free(); + this._elementMap.delete(elementId); } } @@ -131,6 +286,14 @@ export class YogaManager { throw new Error(`Parent or child node not found for IDs ${parentId} and ${childId}.`); } + // A measured text leaf can't have Yoga children (Yoga forbids children on + // a node with a measure func). Text fragment children — e.g. the strings a + // renders to — are folded into the parent's text by the + // renderer, so they're not layout participants here. + if (parentYogaNode.text !== undefined) { + return; + } + index ??= childYogaNode.children.length; parentYogaNode.node.insertChild(childYogaNode.node, index); @@ -178,6 +341,15 @@ export class YogaManager { throw new Error('Yoga is not initialized! Did you call `init()`?'); } + // Inside a synchronous flush, a re-dirty (e.g. the grow-only text remeasure + // reacting to this pass) just asks the flush loop for another pass rather + // than scheduling a separate microtask. + if (this._isFlushing) { + this._needsAnotherPass = true; + + return; + } + if (this._isRenderQueued) { return; } @@ -194,28 +366,80 @@ export class YogaManager { return; } - this._initializeArrayBuffer(); - - for (const independentRoot of this._independentRoots) { - // undefined available size → yoga uses the root's own w/h (or - // shrink-to-fit). Passing 1920×1080 would stretch any unset axis - // and break measurement-driven roots like VirtualList cells. - independentRoot.node.calculateLayout( - undefined, - undefined, - // oxlint-disable-next-line typescript/no-non-null-assertion -- Already checked this._yoga above - this._yoga!.DIRECTION_LTR, - ); - this._getUpdatedStyles(independentRoot, force); - } + this._runLayoutPass(force); - this._flushArrayBuffer(this._dataView.buffer); + // A pass that drove no re-dirty (isRenderQueued still false) has + // converged. One pass per microtask keeps the async timing unchanged. + if (!this._isRenderQueued) { + this._eventEmitter.emit('settled'); + } }); } + /** + * Lay out synchronously, looping until no pass re-dirties, then emit + * `settled`. Only possible on the main thread (worker mode has no sync + * round-trip). Callers that need a size before the next frame use this. + */ + public flushLayout(force = false): void { + if (!this._initialized || !this._yoga) { + throw new Error('Yoga is not initialized! Did you call `init()`?'); + } + + // We are laying out now, so cancel any pending async flush. + this._isRenderQueued = false; + this._flushToFixpoint(force); + } + + private _flushToFixpoint(force: boolean): void { + if (this._isFlushing || this._independentRoots.size === 0) { + return; + } + + this._isFlushing = true; + + let passes = 0; + + do { + this._needsAnotherPass = false; + this._runLayoutPass(force); + } while (this._needsAnotherPass && ++passes < MAX_LAYOUT_PASSES); + + const converged = !this._needsAnotherPass; + + this._isFlushing = false; + this._needsAnotherPass = false; + + if (!converged) { + console.warn(`Layout did not settle after ${MAX_LAYOUT_PASSES} passes.`); + } + + this._eventEmitter.emit('settled'); + } + + private _runLayoutPass(force: boolean): void { + this._initializeArrayBuffer(); + + for (const independentRoot of this._independentRoots) { + // undefined available size → yoga uses the root's own w/h (or + // shrink-to-fit). Passing 1920×1080 would stretch any unset axis + // and break measurement-driven roots like VirtualList cells. + independentRoot.node.calculateLayout( + undefined, + undefined, + // oxlint-disable-next-line typescript/no-non-null-assertion -- Already checked this._yoga above + this._yoga!.DIRECTION_LTR, + ); + this._getUpdatedStyles(independentRoot, force); + } + + this._flushArrayBuffer(this._dataView.buffer); + } + public applyStyles( styles: Record>, skipRender = false, + resets?: Record, ): void { if (!this._initialized) { throw new Error('Yoga was not initialized! Did you call `init()`?'); @@ -224,8 +448,11 @@ export class YogaManager { // `for...in` skips the [key, value] tuple allocation of Object.entries — // this is a hot path on every flushBoth/applyStyles message. for (const elementId in styles) { - // oxlint-disable-next-line typescript/no-non-null-assertion -- key from for..in iteration of own props - this.applyStyle(+elementId, styles[elementId as unknown as number]!, skipRender); + const style = styles[elementId as unknown as number]; + + if (style !== undefined) { + this.applyStyle(+elementId, style, skipRender, resets?.[elementId as unknown as number] === 1); + } } } @@ -233,6 +460,7 @@ export class YogaManager { elementId: number, style: Partial | null, skipRender = false, + resetMissing = false, ): void { if (!style) { return; @@ -250,7 +478,7 @@ export class YogaManager { return; } - applyReactPropsToYoga(this._yoga, this._yogaOptions, yogaNode, style); + applyReactPropsToYoga(this._yoga, this._yogaOptions, yogaNode, style, resetMissing); if (style.transform) { const { x, y, transform } = style; @@ -258,29 +486,51 @@ export class YogaManager { // Apply transforms after all the styles are applied if (transform) { const { translateX, translateY } = transform; - - if (translateX != null) { - const left = x ?? 0; - - applyFlexPropToYoga( - this._yoga, - this._yogaOptions, - yogaNode.node, - 'left', - left + translateX, + const yoga = this._yoga; + const node = yogaNode.node; + + // A string translate is a percentage of the node's OWN size, which + // yoga can't express as a position edge — stash it and resolve at + // readback (_getUpdatedStyles). Recomputed on every transform push. + yogaNode.translatePercent = undefined; + yogaNode.resolvedTranslate = undefined; + + if (typeof translateX === 'string') { + const pct = Number.parseFloat(translateX); + + // A garbage percentage (e.g. arithmetic on an animation object + // upstream produced 'NaN%') must not move the node. + if (!Number.isNaN(pct)) { + (yogaNode.translatePercent ??= {}).x = pct; + } + } else if (translateX != null) { + const right = node.getPosition(yoga.EDGE_RIGHT); + const { edge, value } = resolveHorizontalTranslate( + right.unit === yoga.UNIT_POINT, + x ?? 0, + right.value, + translateX, ); - } - if (translateY != null) { - const top = y ?? 0; + applyFlexPropToYoga(yoga, this._yogaOptions, node, edge, value); + } - applyFlexPropToYoga( - this._yoga, - this._yogaOptions, - yogaNode.node, - 'top', - top + translateY, + if (typeof translateY === 'string') { + const pct = Number.parseFloat(translateY); + + if (!Number.isNaN(pct)) { + (yogaNode.translatePercent ??= {}).y = pct; + } + } else if (translateY != null) { + const bottom = node.getPosition(yoga.EDGE_BOTTOM); + const { edge, value } = resolveVerticalTranslate( + bottom.unit === yoga.UNIT_POINT, + y ?? 0, + bottom.value, + translateY, ); + + applyFlexPropToYoga(yoga, this._yogaOptions, node, edge, value); } } } @@ -324,30 +574,74 @@ export class YogaManager { private _getUpdatedStyles(yogaNode: ManagerNode, force = false) { const skipHiddenNode = !this._yogaOptions.processHiddenNodes && this._hiddenElements.has(yogaNode.id); + // A percent translate never dirties yoga (there's no edge to write), so a + // percent node is visited on every pass; the dedupe below keeps it from + // re-emitting while its resolved position holds. + const translatePercent = yogaNode.translatePercent; + const hasNewLayout = force || yogaNode.node.hasNewLayout(); - if (!skipHiddenNode && (force || yogaNode.node.hasNewLayout())) { - if (!this._dataView.hasSpace(APPROX_SIZEOF_UPDATE)) { - this._flushArrayBuffer(this._dataView.buffer); - } - + if (!skipHiddenNode && (hasNewLayout || translatePercent !== undefined)) { // Individual getters instead of getComputedLayout() — that allocates // a {left, top, width, height} object per node, and we recurse the // entire yoga tree every layout pass. const node = yogaNode.node; - // Direct DataView writes — hasSpace above already validated the full - // 12-byte run, so per-call overflow checks are pure overhead here. - const view = this._dataView.dataView; - const offset = this._dataView.offset; + let left = node.getComputedLeft(); + let top = node.getComputedTop(); + const width = node.getComputedWidth(); + const height = node.getComputedHeight(); + + // A percentage translate is a fraction of the node's OWN size (RN + // semantics), resolvable only now that layout has computed that size. + if (translatePercent !== undefined) { + if (translatePercent.x !== undefined) { + left += (translatePercent.x / 100) * width; + } + + if (translatePercent.y !== undefined) { + top += (translatePercent.y / 100) * height; + } + } + + const resolved = yogaNode.resolvedTranslate; + const skipWrite = + !hasNewLayout && + translatePercent !== undefined && + resolved !== undefined && + resolved.left === left && + resolved.top === top; + + if (!skipWrite) { + if (!this._dataView.hasSpace(APPROX_SIZEOF_UPDATE)) { + this._flushArrayBuffer(this._dataView.buffer); + } - view.setUint32(offset, yogaNode.id, true); - view.setInt16(offset + 4, node.getComputedLeft(), true); - view.setInt16(offset + 6, node.getComputedTop(), true); - view.setInt16(offset + 8, node.getComputedWidth(), true); - view.setInt16(offset + 10, node.getComputedHeight(), true); - this._dataView.advance(APPROX_SIZEOF_UPDATE); + // Direct DataView writes — hasSpace above already validated the full + // 12-byte run, so per-call overflow checks are pure overhead here. + const view = this._dataView.dataView; + const offset = this._dataView.offset; + + view.setUint32(offset, yogaNode.id, true); + // Int32, not Int16: a long list lays out well past 32767px and the + // narrower field wraps those positions negative (rows paint over the + // screen top and spans computed from them go haywire). + view.setInt32(offset + 4, left, true); + view.setInt32(offset + 8, top, true); + view.setInt32(offset + 12, width, true); + view.setInt32(offset + 16, height, true); + this._dataView.advance(APPROX_SIZEOF_UPDATE); + + if (translatePercent !== undefined) { + if (resolved === undefined) { + yogaNode.resolvedTranslate = { left, top }; + } else { + resolved.left = left; + resolved.top = top; + } + } - node.markLayoutSeen(); + node.markLayoutSeen(); + } } const children = yogaNode.children; diff --git a/packages/plugin-flexbox/src/YogaManager.wire.test.ts b/packages/plugin-flexbox/src/YogaManager.wire.test.ts new file mode 100644 index 00000000..f80ba083 --- /dev/null +++ b/packages/plugin-flexbox/src/YogaManager.wire.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; + +import { SimpleDataView } from './util/SimpleDataView'; +import { YogaManager } from './YogaManager'; + +function decode(buffer: ArrayBuffer) { + const view = new SimpleDataView(buffer); + const out: Record = {}; + + while (view.offset < buffer.byteLength) { + const id = view.readUint32(); + const x = view.readInt32(); + const y = view.readInt32(); + const w = view.readInt32(); + const h = view.readInt32(); + + out[id] = { x, y, w, h }; + } + + return out; +} + +// The update buffer used int16 for x/y/w/h; a long virtualized list lays out +// well past 32767px and those positions wrapped negative (rows painted over +// the screen top, and viewport spans derived from them went haywire). +describe('YogaManager update wire format', () => { + it('keeps positions past the int16 range intact', async () => { + const m = new YogaManager(); + await m.init(); + + m.addNode(1); + m.applyStyle(1, { display: 'flex', flexDirection: 'column', w: 100 }, true); + m.addIndependentRoot(1); + + m.addNode(2); + m.applyStyle(2, { h: 40000, w: 100 }, true); + m.addChildNode(1, 2, 0); + + m.addNode(3); + m.applyStyle(3, { h: 100, w: 100 }, true); + m.addChildNode(1, 3, 1); + + let layout: ReturnType = {}; + + m.on('render', (buf) => { + layout = { ...layout, ...decode(buf) }; + }); + m.flushLayout(); + + expect(layout[3]?.y).toBe(40000); + expect(layout[1]?.h).toBe(40100); + }); +}); diff --git a/packages/plugin-flexbox/src/YogaManagerWorker.ts b/packages/plugin-flexbox/src/YogaManagerWorker.ts index 6dd36280..a572c75f 100644 --- a/packages/plugin-flexbox/src/YogaManagerWorker.ts +++ b/packages/plugin-flexbox/src/YogaManagerWorker.ts @@ -2,7 +2,9 @@ import { EventEmitter } from 'tseep'; import type { LightningElementStyle } from '@plextv/react-lightning'; +import { resolveAtlasUrl } from './text/resolveAtlasUrl'; import { NodeOperations } from './types/NodeOperations'; +import type { YogaOptions } from './types/YogaOptions'; import { isFlexStyleProp } from './util/isFlexStyleProp'; import { SimpleDataView } from './util/SimpleDataView'; import { toSerializableValue } from './util/toSerializableValue'; @@ -50,6 +52,9 @@ function wrapWorker(worker: Worker): Workerized { const _callees: Record = {}; const _eventEmitter = new EventEmitter(); let _stylesToSend: Record> = {}; + // Elements whose buffered style is a full snapshot: the worker resets + // previously-set flex props missing from it (see applyReactPropsToYoga). + let _resetsToSend: Record = {}; let _numStylesToSend = 0; let _needsRender = false; const _childOperations = new SimpleDataView(undefined, undefined, _onChildOpsOverflow); @@ -85,11 +90,12 @@ function wrapWorker(worker: Worker): Workerized { worker.postMessage({ method: 'applyStyles', - args: [_stylesToSend, !_needsRender], + args: [_stylesToSend, !_needsRender, _resetsToSend], }); _needsRender = false; _stylesToSend = {}; + _resetsToSend = {}; _numStylesToSend = 0; } @@ -99,8 +105,13 @@ function wrapWorker(worker: Worker): Workerized { elementId: number, style: Partial | null, skipRender = false, + resetMissing = false, ) { if (style) { + if (resetMissing) { + _resetsToSend[elementId] = 1; + } + let styleToSend = _stylesToSend[elementId]; if (!styleToSend) { @@ -112,8 +123,10 @@ function wrapWorker(worker: Worker): Workerized { // `for...in` skips Object.entries' tuple allocation — hot path on // every applyStyle. Filter non-flex keys here so we don't serialize // them, ship them across postMessage, and let the worker re-filter. + // `transform` is not a flex prop but the worker applies it as a + // top/left offset on the laid-out position, so let it through. for (const key in style) { - if (!isFlexStyleProp(key)) { + if (key !== 'transform' && !isFlexStyleProp(key)) { continue; } @@ -134,6 +147,7 @@ function wrapWorker(worker: Worker): Workerized { } delete _stylesToSend[elementId]; + delete _resetsToSend[elementId]; _numStylesToSend--; } @@ -182,13 +196,14 @@ function wrapWorker(worker: Worker): Workerized { worker.postMessage( { method: 'flushBoth', - args: [buffer, _stylesToSend, !_needsRender], + args: [buffer, _stylesToSend, !_needsRender, _resetsToSend], }, [buffer], ); _childOperations.reset(); _stylesToSend = {}; + _resetsToSend = {}; _numStylesToSend = 0; _needsRender = false; } @@ -352,12 +367,46 @@ function wrapWorker(worker: Worker): Workerized { }, addIndependentRoot: (elementId: number) => nodeOperation('addIndependentRoot', elementId), removeIndependentRoot: (elementId: number) => nodeOperation('removeIndependentRoot', elementId), - init: (yogaOptions?: unknown) => _awaitable('init', [yogaOptions]), + // Text measurement ops must land after the node's addNode (and any pending + // styles), so flush the buffered pipeline before posting them. + setTextMeasure: (elementId: number, fontFamily: string, props: unknown) => { + flushChildOperations(); + flushSendStyles(); + worker.postMessage({ + method: 'setTextMeasure', + args: [elementId, fontFamily, props], + }); + }, + clearTextMeasure: (elementId: number) => { + flushChildOperations(); + worker.postMessage({ method: 'clearTextMeasure', args: [elementId] }); + }, + init: (yogaOptions?: unknown) => + _awaitable('init', [resolveFontUrls(yogaOptions as YogaOptions | undefined)]), }; return proxy as unknown as Workerized; } +// The worker is inlined as a blob, so a root-relative atlas URL can't resolve +// against its base once it's over there. Resolve here on the main thread, where +// `location` is the real document URL, before the options cross postMessage. +function resolveFontUrls(yogaOptions?: YogaOptions): YogaOptions | undefined { + if (!yogaOptions?.fonts?.length) { + return yogaOptions; + } + + const baseHref = globalThis.location?.href; + + return { + ...yogaOptions, + fonts: yogaOptions.fonts.map((font) => ({ + ...font, + atlasDataUrl: resolveAtlasUrl(font.atlasDataUrl, baseHref), + })), + }; +} + let count = 0; function getId(): number { return ++count; diff --git a/packages/plugin-flexbox/src/index.border.spec.ts b/packages/plugin-flexbox/src/index.border.spec.ts new file mode 100644 index 00000000..200825e2 --- /dev/null +++ b/packages/plugin-flexbox/src/index.border.spec.ts @@ -0,0 +1,40 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { plugin } from './index'; +import { LightningManager } from './LightningManager'; + +// Border must reach both sides of the split: the numeric width goes to Yoga so +// it reserves the box (border-box), and the full border style stays on the +// element so the renderer still paints it. Losing either half is a regression +// (no layout reservation -> tabs jump; stripped from the renderer -> no ring). +describe('flexbox plugin transformProps border routing', () => { + function transform(style: Record) { + const applyStyle = vi + .spyOn(LightningManager.prototype, 'applyStyle') + .mockImplementation(() => {}); + + const p = plugin(); + // oxlint-disable-next-line typescript/no-explicit-any -- minimal fake element/props + const result = p.transformProps?.({ id: 1 } as any, { style } as any) as any; + + const flexStyles = applyStyle.mock.calls[0]?.[1]; + applyStyle.mockRestore(); + + return { flexStyles, remainingStyles: result.style }; + } + + it('sends the numeric border width to yoga and keeps the style for the renderer', () => { + const { flexStyles, remainingStyles } = transform({ + border: { w: 2, color: 0xffffffff }, + }); + + expect(flexStyles).toMatchObject({ border: 2 }); + expect(remainingStyles).toMatchObject({ border: { w: 2, color: 0xffffffff } }); + }); + + it('passes a zero border through so the reservation clears on deselect', () => { + const { flexStyles } = transform({ border: { w: 0, color: 0 } }); + + expect(flexStyles).toMatchObject({ border: 0 }); + }); +}); diff --git a/packages/plugin-flexbox/src/index.partialStyle.spec.ts b/packages/plugin-flexbox/src/index.partialStyle.spec.ts new file mode 100644 index 00000000..1bd3342a --- /dev/null +++ b/packages/plugin-flexbox/src/index.partialStyle.spec.ts @@ -0,0 +1,36 @@ +import { PARTIAL_STYLE } from '@plextv/react-lightning'; +import { describe, expect, it, vi } from 'vitest'; + +import { plugin } from './index'; +import { LightningManager } from './LightningManager'; + +// Animated pushes (reanimated) re-enter setProps with only the keys the +// updater computed. Treating that as a full style snapshot resets every other +// flex prop in yoga — an absolutely positioned element with an animated +// marginLeft fell back into flow and pushed its siblings around. +describe('flexbox plugin transformProps partial styles', () => { + function transform(style: Record) { + const applyStyle = vi + .spyOn(LightningManager.prototype, 'applyStyle') + .mockImplementation(() => {}); + + const p = plugin(); + + // oxlint-disable-next-line typescript/no-explicit-any -- minimal fake element/props + p.transformProps?.({ id: 1 } as any, { style } as any); + + const resetMissing = applyStyle.mock.calls[0]?.[3]; + + applyStyle.mockRestore(); + + return resetMissing; + } + + it('resets missing flex props for a full style snapshot', () => { + expect(transform({ marginLeft: 500 })).toBe(true); + }); + + it('keeps missing flex props for a PARTIAL_STYLE-marked style', () => { + expect(transform({ marginLeft: 500, [PARTIAL_STYLE]: true })).toBe(false); + }); +}); diff --git a/packages/plugin-flexbox/src/index.ts b/packages/plugin-flexbox/src/index.ts index 4bfa0008..d2c07ff7 100644 --- a/packages/plugin-flexbox/src/index.ts +++ b/packages/plugin-flexbox/src/index.ts @@ -1,10 +1,34 @@ +import { PARTIAL_STYLE } from '@plextv/react-lightning'; import type { LightningElement, LightningElementStyle, Plugin } from '@plextv/react-lightning'; import { LightningManager } from './LightningManager'; -import { setFlexboxManager } from './manager'; +import { getFlexboxManager, setFlexboxManager } from './manager'; import type { YogaOptions } from './types/YogaOptions'; import { flexProps, isFlexStyleProp } from './util/isFlexStyleProp'; +const BORDER_PROPS: ReadonlySet = new Set([ + 'border', + 'borderTop', + 'borderRight', + 'borderBottom', + 'borderLeft', +]); + +// Yoga only wants the numeric edge width; the renderer keeps the full +// border style (css-transform hands us `{ w, color }`, a bare number, or a +// per-edge number). +function borderWidth(value: unknown): number { + if (typeof value === 'number') { + return value; + } + + if (value != null && typeof value === 'object' && 'w' in value) { + return (value as { w?: number }).w ?? 0; + } + + return 0; +} + export function plugin(yogaOptions?: YogaOptions): Plugin { const lightningManager = new LightningManager(); @@ -63,6 +87,15 @@ export function plugin(yogaOptions?: YogaOptions): Plugin { // Width and height go to both flex and remaining styles flexStyles[key] = value; remainingStyles[key] = value; + } else if (BORDER_PROPS.has(key)) { + // Border reaches both: the renderer paints it, Yoga reserves its + // box (border-box, like react-native) so a `margin: -border` + // compensation doesn't shift the content when the border toggles. + remainingStyles[key] = value; + + if (value != null) { + flexStyles[key] = borderWidth(value); + } } else if (isFlexStyleProp(key) && value != null) { flexStyles[key] = value; } else { @@ -70,7 +103,19 @@ export function plugin(yogaOptions?: YogaOptions): Plugin { } } - lightningManager.applyStyle(instance.id, flexStyles as Partial, true); + // flexStyles is complete for this render, so a missing key means the + // prop was dropped: reset it instead of leaving yoga's stale value. + // Styles marked PARTIAL_STYLE (animated pushes) only carry the changed + // keys, so resetting the rest would wipe static flex props like + // `position: 'absolute'`. + const isPartialStyle = (styles as Record)[PARTIAL_STYLE] === true; + + lightningManager.applyStyle( + instance.id, + flexStyles as Partial, + true, + !isPartialStyle, + ); return { ...props, @@ -80,6 +125,14 @@ export function plugin(yogaOptions?: YogaOptions): Plugin { }; } +/** + * Subscribe to the flexbox layout-settled signal (Yoga converged). Returns an + * unsubscribe fn. A no-op unsubscribe when no manager is mounted yet. + */ +export function onFlexLayoutSettled(callback: () => void): () => void { + return getFlexboxManager()?.onSettled(callback) ?? (() => {}); +} + export { FlexBoundary, FlexRoot, useIsInFlex } from './wrappers'; export type { FlexBoundaryProps, FlexRootProps } from './wrappers'; export * from './types'; diff --git a/packages/plugin-flexbox/src/measureText.integration.test.ts b/packages/plugin-flexbox/src/measureText.integration.test.ts new file mode 100644 index 00000000..7cd7f91b --- /dev/null +++ b/packages/plugin-flexbox/src/measureText.integration.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it } from 'vitest'; + +import type { AtlasData } from './text/FontMetricsStore'; +import { YogaManager } from './YogaManager'; + +// Same synthetic atlas as layoutText.test.ts: at fontSize 20, "aa" = 40px wide, +// a space = 10px, line height = 20px. +const atlas: AtlasData = { + info: { size: 10, face: 'Test' }, + common: { lineHeight: 12, base: 8 }, + lightningMetrics: { + ascender: 800, + descender: -200, + lineGap: 0, + unitsPerEm: 1000, + }, + chars: [ + { id: 97, xadvance: 10, xoffset: 0, yoffset: 0, width: 8, height: 8 }, + { id: 32, xadvance: 5, xoffset: 0, yoffset: 0, width: 0, height: 0 }, + ], + kernings: [], +}; + +const textProps = { + text: 'aa aa', + fontSize: 20, + letterSpacing: 0, + lineHeight: 1, + maxLines: 0, + maxHeight: 0, + wordBreak: 'break-word' as const, + overflowSuffix: '...', +}; + +type Computed = Map; + +function nextRender(manager: YogaManager): Promise { + return new Promise((resolve) => { + const handler = (buffer: ArrayBuffer) => { + manager.off('render', handler); + + const view = new DataView(buffer); + const out: Computed = new Map(); + + for (let offset = 0; offset + 20 <= buffer.byteLength; offset += 20) { + out.set(view.getUint32(offset, true), { + x: view.getInt32(offset + 4, true), + y: view.getInt32(offset + 8, true), + w: view.getInt32(offset + 12, true), + h: view.getInt32(offset + 16, true), + }); + } + + resolve(out); + }; + + manager.on('render', handler); + manager.queueRender(1, true); + }); +} + +async function setup(rootStyle: Record) { + const manager = new YogaManager(); + await manager.init(); + // Inject the synthetic font synchronously (skip the async URL fetch). + ( + manager as unknown as { + _fontStore: { register: (f: string, d: AtlasData) => void }; + } + )._fontStore.register('Test', atlas); + + manager.addNode(1); + manager.applyStyle(1, rootStyle, true); + manager.addIndependentRoot(1); + + manager.addNode(2); + manager.addChildNode(1, 2); + manager.setTextMeasure(2, 'Test', textProps); + + return manager; +} + +describe('Yoga text measurement (real yoga)', () => { + it('wraps a stretched text child to the container width', async () => { + // 60px-wide column, child stretches to fill width (align-items: stretch) → + // the measure func gets Exactly(60) and wraps "aa aa" to 60px. + const manager = await setup({ + display: 'flex', + flexDirection: 'column', + alignItems: 'stretch', + w: 60, + h: 200, + }); + + const computed = await nextRender(manager); + const text = computed.get(2); + + expect(text?.w).toBe(60); // stretched to container + expect(text?.h).toBe(40); // "aa" / "aa" → 2 lines × 20px + }); + + it('shrinks an unstretched text child to its content width', async () => { + // align-items flex-start → child sized to measured content, unconstrained. + const manager = await setup({ + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', + w: 300, + h: 200, + }); + + const computed = await nextRender(manager); + const text = computed.get(2); + + expect(text?.w).toBe(90); // "aa aa" single line = 45 design × 2 + expect(text?.h).toBe(20); // 1 line + }); + + it('ignores children of a measured text leaf (stays a leaf)', async () => { + const manager = await setup({ + display: 'flex', + flexDirection: 'column', + alignItems: 'stretch', + w: 60, + h: 200, + }); + + // A text fragment child must not become a Yoga child (Yoga forbids + // children on a measure-func node) — this must not throw or change size. + manager.addNode(3); + expect(() => manager.addChildNode(2, 3)).not.toThrow(); + + const computed = await nextRender(manager); + expect(computed.get(2)?.h).toBe(40); // still measured as 2 lines + }); + + it('keeps measuring text correctly after a parent style re-apply drops a prop', async () => { + // Starts unstretched (flex-start) so the child is sized to its own content. + const manager = await setup({ + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', + w: 300, + h: 200, + }); + + const before = (await nextRender(manager)).get(2); + + expect(before?.w).toBe(90); // shrink-to-content, unwrapped + expect(before?.h).toBe(20); + + // Drop `alignItems`: resets to yoga's stretch default, shouldn't clobber + // the text's own measured sizing. + manager.applyStyle(1, { display: 'flex', flexDirection: 'column', w: 300, h: 200 }, true, true); + + const after = (await nextRender(manager)).get(2); + + expect(after?.w).toBe(300); // stretched to the container + expect(after?.h).toBe(20); // still measures a single line correctly + }); + + it('awaits font metrics during init so the first layout measures text', async () => { + // If init resolves before the atlas JSON is registered, the first layout + // measures text 0x0 and the font-arrival re-measure reflows the whole + // tree while it is already visible (the boot-time position jump). + const originalFetch = globalThis.fetch; + // Resolve on a macrotask, like a real network fetch — a same-tick stub + // would land before the first layout microtask and mask the race. + globalThis.fetch = (async () => { + await new Promise((resolve) => setTimeout(resolve, 5)); + + return { json: async () => atlas }; + }) as unknown as typeof fetch; + + try { + const manager = new YogaManager(); + await manager.init({ + fonts: [{ fontFamily: 'Test', atlasDataUrl: 'test://atlas.json' }], + }); + + manager.addNode(1); + manager.applyStyle(1, { display: 'flex', w: 100, h: 100 }, true); + manager.addIndependentRoot(1); + + manager.addNode(2); + manager.addChildNode(1, 2); + manager.setTextMeasure(2, 'Test', textProps); + + const computed = await nextRender(manager); + + // "aa aa" at fontSize 20 with the synthetic atlas is 90px wide unwrapped. + expect(computed.get(2)?.w).toBeGreaterThan(0); + expect(computed.get(2)?.h).toBeGreaterThan(0); + } finally { + globalThis.fetch = originalFetch; + } + }); +}); diff --git a/packages/plugin-flexbox/src/text/FontMetricsStore.ts b/packages/plugin-flexbox/src/text/FontMetricsStore.ts new file mode 100644 index 00000000..f943c52b --- /dev/null +++ b/packages/plugin-flexbox/src/text/FontMetricsStore.ts @@ -0,0 +1,185 @@ +/** + * Synchronous msdf font metrics for the Yoga worker. + * + * Yoga measures text leaves during layout, on whatever thread it runs on + * (here, a web worker). The renderer's own text measurement is async and lives + * on the main thread, so it can't answer "how tall is this text at width W?" + * mid-layout. This store loads the same msdf atlas JSON the renderer uses and + * reproduces its glyph-advance maths so the worker can measure text itself. + * + * Width maths run in atlas *design units* (raw `xadvance`), exactly like + * `@lightningjs/renderer`'s `SdfFontHandler.measureText`, so wrap results match + * what the renderer will paint. Callers convert px↔design units with + * `fontScale = fontSize / designFontSize`. See `layoutText.ts`. + */ + +export interface AtlasChar { + id: number; + xadvance: number; + xoffset: number; + yoffset: number; + width: number; + height: number; +} + +export interface AtlasKerning { + first: number; + second: number; + amount: number; +} + +/** OpenType-style metrics the msdf-generator embeds, in em units. */ +export interface LightningMetrics { + ascender: number; + descender: number; + lineGap: number; + unitsPerEm: number; +} + +export interface AtlasData { + info: { size: number; face?: string }; + common: { lineHeight: number; base: number }; + chars: AtlasChar[]; + kernings?: AtlasKerning[]; + lightningMetrics?: LightningMetrics; +} + +// Mirrors @lightningjs/renderer's TextLayoutEngine default. +const DEFAULT_METRICS: LightningMetrics = { + ascender: 800, + descender: -200, + lineGap: 200, + unitsPerEm: 1000, +}; + +// second glyph id → (first glyph id → kerning amount), matching the renderer's +// buildKerningTable layout for O(1) pair lookup. +type KerningTable = Map>; + +const isZeroWidthSpace = (codepoint: number): boolean => codepoint === 0x200b; + +// Matches the renderer's SdfFont.getGlyph fallback for glyphs missing from the atlas. +const MISSING_GLYPH_FALLBACK_CODEPOINT = 0x3f; // '?' + +export class FontMetrics { + public readonly designFontSize: number; + public readonly metrics: LightningMetrics; + + private readonly _glyphs = new Map(); + private readonly _kernings: KerningTable = new Map(); + + public constructor(data: AtlasData) { + this.designFontSize = data.info.size; + this.metrics = data.lightningMetrics ?? DEFAULT_METRICS; + + for (const glyph of data.chars) { + // BMFont `id` is the unicode codepoint; key by it for codepoint lookup. + this._glyphs.set(glyph.id, glyph); + } + + if (data.kernings) { + for (const { first, second, amount } of data.kernings) { + let firsts = this._kernings.get(second); + + if (firsts === undefined) { + firsts = new Map(); + this._kernings.set(second, firsts); + } + + firsts.set(first, amount); + } + } + } + + public getKerning(firstGlyphId: number, secondGlyphId: number): number { + return this._kernings.get(secondGlyphId)?.get(firstGlyphId) ?? 0; + } + + /** + * Width of `text` in atlas design units (port of + * `SdfFontHandler.measureText`). `letterSpacing` is also in design units. + */ + public measureText(text: string, letterSpacing: number): number { + if (text.length === 0) { + return 0; + } + + let width = 0; + let prevCodepoint = 0; + + for (const char of text) { + const codepoint = char.codePointAt(0); + + if (codepoint === undefined || isZeroWidthSpace(codepoint)) { + continue; + } + + const glyph = + this._glyphs.get(codepoint) ?? this._glyphs.get(MISSING_GLYPH_FALLBACK_CODEPOINT); + + if (glyph === undefined) { + continue; + } + + let advance = glyph.xadvance; + + // Kerning stays keyed on the real codepoint (not the ? fallback): the + // renderer only substitutes the glyph used for the advance, not the pair. + if (prevCodepoint !== 0) { + advance += this.getKerning(prevCodepoint, codepoint); + } + + width += advance + letterSpacing; + prevCodepoint = codepoint; + } + + return width; + } +} + +/** Loads and caches one `FontMetrics` per font family from its atlas JSON URL. */ +export class FontMetricsStore { + private readonly _fonts = new Map(); + private readonly _loading = new Map>(); + + public has(fontFamily: string): boolean { + return this._fonts.has(fontFamily); + } + + public get(fontFamily: string): FontMetrics | undefined { + return this._fonts.get(fontFamily); + } + + public register(fontFamily: string, data: AtlasData): void { + this._fonts.set(fontFamily, new FontMetrics(data)); + } + + /** Fetch + register an atlas JSON. Deduplicated per family; never throws. */ + public async load(fontFamily: string, atlasDataUrl: string): Promise { + if (this._fonts.has(fontFamily)) { + return; + } + + let pending = this._loading.get(fontFamily); + + if (pending === undefined) { + pending = (async () => { + try { + const response = await fetch(atlasDataUrl); + const data = (await response.json()) as AtlasData; + this.register(fontFamily, data); + } catch (error) { + // A missing/late font just means text stays unmeasured (single-line + // fallback) until it loads — not a layout-fatal error. + console.warn(`[flexbox] failed to load font metrics for ${fontFamily}`, error); + } finally { + this._loading.delete(fontFamily); + } + })(); + + this._loading.set(fontFamily, pending); + } + + return pending; + } +} diff --git a/packages/plugin-flexbox/src/text/layoutText.test.ts b/packages/plugin-flexbox/src/text/layoutText.test.ts new file mode 100644 index 00000000..4e7a93b1 --- /dev/null +++ b/packages/plugin-flexbox/src/text/layoutText.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'vitest'; + +import { type AtlasData, FontMetrics } from './FontMetricsStore'; +import { layoutText, type TextMeasureProps } from './layoutText'; + +// Synthetic atlas with round numbers so expectations are exact: +// designFontSize 10, unitsPerEm 1000, ascender 800, descender -200. +// 'a'/'b' advance 10 design units, space advance 5. +const atlas: AtlasData = { + info: { size: 10, face: 'Test' }, + common: { lineHeight: 12, base: 8 }, + lightningMetrics: { + ascender: 800, + descender: -200, + lineGap: 0, + unitsPerEm: 1000, + }, + chars: [ + { id: 97, xadvance: 10, xoffset: 0, yoffset: 0, width: 8, height: 8 }, // a + { id: 98, xadvance: 10, xoffset: 0, yoffset: 0, width: 8, height: 8 }, // b + { id: 32, xadvance: 5, xoffset: 0, yoffset: 0, width: 0, height: 0 }, // (space) + { id: 46, xadvance: 3, xoffset: 0, yoffset: 0, width: 2, height: 2 }, // . + { id: 63, xadvance: 7, xoffset: 0, yoffset: 0, width: 6, height: 8 }, // ? + ], + kernings: [{ first: 97, second: 63, amount: -2 }], // a? kerns; unrelated to any missing-glyph pair +}; + +const font = new FontMetrics(atlas); + +const props = (over: Partial = {}): TextMeasureProps => ({ + text: 'aa', + fontSize: 20, // → fontScale 2 + letterSpacing: 0, + lineHeight: 1, + maxLines: 0, + maxHeight: 0, + wordBreak: 'break-word', + overflowSuffix: '...', + ...over, +}); + +// At fontSize 20, em scale 20/1000 = 0.02 → bareLineHeight = (800−(−200))·0.02 = 20. +const LINE_PX = 20; + +describe('FontMetrics.measureText', () => { + it('sums glyph advances in design units', () => { + expect(font.measureText('aa', 0)).toBe(20); + expect(font.measureText('aa aa', 0)).toBe(45); // 10+10+5+10+10 + }); + + it('applies letter spacing per glyph (design units)', () => { + expect(font.measureText('aa', 2)).toBe(24); // (10+2)+(10+2) + }); + + it('falls back to the ? glyph advance for a codepoint missing from the atlas', () => { + // 'z' (122) isn't in the atlas; renderer substitutes ?'s xadvance (7). + expect(font.measureText('z', 0)).toBe(7); + expect(font.measureText('az', 0)).toBe(font.measureText('a', 0) + 7); + }); + + it('keys kerning off the real codepoint, not the substituted glyph', () => { + // "a?" kerns (table has a 97/63 pair); "az" doesn't, since the renderer + // looks up kerning by the real (missing) codepoint, only the advance falls back. + expect(font.measureText('a?', 0)).toBe(10 + 7 - 2); + expect(font.measureText('az', 0)).toBe(10 + 7); + }); +}); + +describe('layoutText', () => { + it('measures a single unconstrained line, scaling design→px', () => { + const { width, height } = layoutText(font, props({ text: 'aa aa' }), Infinity); + expect(width).toBe(90); // 45 design × fontScale 2 + expect(height).toBe(LINE_PX); + }); + + it('wraps to the available width and reports the tallest stack', () => { + // maxWidth 60px → 30 design. "aa"(20) fits; +space(5)+"aa"(20)=45 > 30 → wrap. + const { width, height } = layoutText(font, props({ text: 'aa aa' }), 60); + expect(width).toBe(40); // widest line "aa" = 20 design × 2 + expect(height).toBe(2 * LINE_PX); + }); + + it('honours explicit newlines when unconstrained', () => { + const { height } = layoutText(font, props({ text: 'aa\nbb\naa' }), Infinity); + expect(height).toBe(3 * LINE_PX); + }); + + it('caps line count via maxLines', () => { + const { height } = layoutText(font, props({ text: 'aa aa aa', maxLines: 2 }), 60); + expect(height).toBe(2 * LINE_PX); + }); + + it('caps line count via maxHeight', () => { + const { height } = layoutText(font, props({ text: 'aa aa aa', maxHeight: 25 }), 60); + // floor(25 / 20) = 1 line + expect(height).toBe(LINE_PX); + }); + + it('treats a pixel lineHeight (>3) as absolute', () => { + const { height } = layoutText(font, props({ text: 'aa', lineHeight: 40 }), Infinity); + expect(height).toBe(40); + }); +}); diff --git a/packages/plugin-flexbox/src/text/layoutText.ts b/packages/plugin-flexbox/src/text/layoutText.ts new file mode 100644 index 00000000..e3bfe35e --- /dev/null +++ b/packages/plugin-flexbox/src/text/layoutText.ts @@ -0,0 +1,424 @@ +/** + * Text block measurement for the Yoga worker — a measurement-focused port of + * `@lightningjs/renderer`'s `TextLayoutEngine` (Apache-2.0). It reproduces the + * renderer's line-wrapping exactly so the size Yoga lays out matches the size + * the renderer paints; only the parts that affect the measured box (line widths + * and line count) are kept — glyph positions, baselines and x-offsets are not. + * + * Width maths run in atlas design units via `FontMetrics.measureText`; the + * public entry point converts to/from px using `fontScale`. + */ + +import type { FontMetrics, LightningMetrics } from './FontMetricsStore'; + +export interface TextMeasureProps { + text: string; + fontSize: number; + letterSpacing: number; + /** ≤3 → multiplier of the natural line height; otherwise px. Matches renderer. */ + lineHeight: number; + maxLines: number; + /** Hard cap in px (0 = none). */ + maxHeight: number; + wordBreak: 'break-word' | 'break-all' | 'overflow'; + overflowSuffix: string; +} + +export interface MeasuredText { + /** px */ + width: number; + /** px */ + height: number; +} + +// [text, width(design units), truncated] +type Line = [string, number, boolean]; + +const spaceRegex = /[ ​]+/g; + +const measure = (font: FontMetrics, text: string, letterSpacing: number): number => + font.measureText(text, letterSpacing); + +const normalizeFontMetrics = (metrics: LightningMetrics, fontSize: number) => { + const scale = fontSize / metrics.unitsPerEm; + + return { + ascender: metrics.ascender * scale, + descender: metrics.descender * scale, + }; +}; + +/** + * Measure a text block within an available width. + * + * @param availableWidth px width to wrap within; `Infinity`/`<=0` means + * unconstrained (no wrapping, single line per `\n`). + * @returns box size in px. + */ +export function layoutText( + font: FontMetrics, + props: TextMeasureProps, + availableWidth: number, +): MeasuredText { + const { text, fontSize, lineHeight, maxLines, maxHeight, wordBreak, overflowSuffix } = props; + + const fontScale = fontSize / font.designFontSize; + // measureText + maxWidth live in design units; px → design via /fontScale. + const letterSpacing = props.letterSpacing / fontScale; + const maxWidth = + availableWidth === Infinity || availableWidth <= 0 ? 0 : availableWidth / fontScale; + + // Line height in px, from em-scaled metrics (renderer parity). + const { ascender, descender } = normalizeFontMetrics(font.metrics, fontSize); + const bareLineHeight = ascender - descender; + const lineHeightPx = lineHeight <= 3 ? lineHeight * bareLineHeight : lineHeight; + + let effectiveMaxLines = maxLines; + + if (maxHeight > 0 && lineHeightPx > 0) { + const maxFromHeight = Math.max(1, Math.floor(maxHeight / lineHeightPx)); + + if (effectiveMaxLines === 0 || maxFromHeight < effectiveMaxLines) { + effectiveMaxLines = maxFromHeight; + } + } + + const lines = + maxWidth > 0 + ? wrapText(font, text, maxWidth, letterSpacing, overflowSuffix, wordBreak, effectiveMaxLines) + : measureLines(font, text.split('\n'), letterSpacing, effectiveMaxLines); + + let widthDesign = 0; + + for (const line of lines) { + if (line[1] > widthDesign) { + widthDesign = line[1]; + } + } + + return { + width: widthDesign * fontScale, + height: lines.length * lineHeightPx, + }; +} + +function measureLines( + font: FontMetrics, + rawLines: string[], + letterSpacing: number, + maxLines: number, +): Line[] { + const limit = maxLines > 0 ? maxLines : rawLines.length; + const out: Line[] = []; + + for (let i = 0; i < rawLines.length && out.length < limit; i++) { + const raw = rawLines[i] ?? ''; + out.push([raw, measure(font, raw, letterSpacing), false]); + } + + return out; +} + +function wrapText( + font: FontMetrics, + text: string, + maxWidth: number, + letterSpacing: number, + overflowSuffix: string, + wordBreak: TextMeasureProps['wordBreak'], + maxLines: number, +): Line[] { + const sourceLines = text.split('\n'); + const wrappedLines: Line[] = []; + const spaceWidth = measure(font, ' ', letterSpacing); + const overflowWidth = measure(font, overflowSuffix, letterSpacing); + const hasMaxLines = maxLines > 0; + let remainingLines = hasMaxLines ? maxLines : 1000; + + for (let i = 0; i < sourceLines.length; i++) { + const line = sourceLines[i] ?? ''; + + const produced = + line.length > 0 + ? wrapLine( + font, + line, + maxWidth, + letterSpacing, + spaceWidth, + overflowSuffix, + overflowWidth, + wordBreak, + remainingLines, + ) + : ([[['', 0, false]], remainingLines] as [Line[], number]); + + remainingLines = produced[1] - 1; + wrappedLines.push(...produced[0]); + + if (hasMaxLines && remainingLines <= 0) { + break; + } + } + + return wrappedLines; +} + +function wrapLine( + font: FontMetrics, + line: string, + maxWidth: number, + letterSpacing: number, + spaceWidth: number, + overflowSuffix: string, + overflowWidth: number, + wordBreak: TextMeasureProps['wordBreak'], + remainingLinesIn: number, +): [Line[], number] { + const words = line.split(spaceRegex); + const spaces = line.match(spaceRegex) || []; + const wrappedLines: Line[] = []; + let currentLine = ''; + let currentLineWidth = 0; + let remainingLines = remainingLinesIn; + + while (words.length > 0 && remainingLines > 0) { + let word = words.shift() ?? ''; + let wordWidth = measure(font, word, letterSpacing); + + if (currentLineWidth === 0) { + if (wordWidth > maxWidth) { + remainingLines--; + + let remainingWord = ''; + [word, remainingWord, wordWidth] = + remainingLines === 0 + ? truncateWord( + font, + word, + wordWidth, + maxWidth, + letterSpacing, + overflowSuffix, + overflowWidth, + ) + : splitWord(font, word, wordWidth, maxWidth, letterSpacing); + + if (remainingWord.length > 0) { + words.unshift(remainingWord); + } + + wrappedLines.push([word, wordWidth, false]); + } else if (wordWidth + spaceWidth >= maxWidth) { + remainingLines--; + wrappedLines.push([word, wordWidth, false]); + } else { + currentLine = word; + currentLineWidth = wordWidth; + } + + continue; + } + + const space = spaces.shift() || ''; + const effectiveSpaceWidth = space === '​' ? 0 : spaceWidth; + const totalWidth = currentLineWidth + effectiveSpaceWidth + wordWidth; + + if (totalWidth < maxWidth) { + currentLine += effectiveSpaceWidth > 0 ? space + word : word; + currentLineWidth = totalWidth; + continue; + } + + remainingLines--; + + if (totalWidth === maxWidth) { + currentLine += effectiveSpaceWidth > 0 ? space + word : word; + wrappedLines.push([currentLine, totalWidth, false]); + currentLine = ''; + currentLineWidth = 0; + continue; + } + + let remainingWord = ''; + [currentLine, currentLineWidth, remainingWord] = breakOntoNextLine( + font, + word, + wordWidth, + letterSpacing, + wrappedLines, + currentLine, + currentLineWidth, + remainingLines, + maxWidth, + space, + spaceWidth, + overflowSuffix, + overflowWidth, + wordBreak, + ); + + if (remainingWord.length > 0) { + words.unshift(remainingWord); + } + } + + if (currentLineWidth > 0 && remainingLines > 0) { + wrappedLines.push([currentLine, currentLineWidth, false]); + } + + return [wrappedLines, remainingLines]; +} + +function breakOntoNextLine( + font: FontMetrics, + word: string, + wordWidth: number, + letterSpacing: number, + wrappedLines: Line[], + currentLine: string, + currentLineWidth: number, + remainingLines: number, + maxWidth: number, + space: string, + spaceWidth: number, + overflowSuffix: string, + overflowWidth: number, + wordBreak: TextMeasureProps['wordBreak'], +): [string, number, string] { + if (wordBreak === 'overflow') { + currentLine += space + word; + currentLineWidth += spaceWidth + wordWidth; + wrappedLines.push([currentLine, currentLineWidth, true]); + return ['', 0, '']; + } + + if (wordBreak === 'break-all') { + let remainingSpace = maxWidth - currentLineWidth; + + if (currentLineWidth > 0) { + remainingSpace -= spaceWidth; + } + + const truncate = remainingLines === 0; + let remainingWord = ''; + [word, remainingWord, wordWidth] = truncate + ? truncateWord( + font, + word, + wordWidth, + remainingSpace, + letterSpacing, + overflowSuffix, + overflowWidth, + ) + : splitWord(font, word, wordWidth, remainingSpace, letterSpacing); + + wrappedLines.push([ + currentLine + space + word, + currentLineWidth + spaceWidth + wordWidth, + truncate, + ]); + return ['', 0, remainingWord]; + } + + // break-word (default): push the current line, carry the whole word over. + wrappedLines.push([currentLine, currentLineWidth, false]); + return ['', 0, word]; +} + +function splitWord( + font: FontMetrics, + word: string, + wordWidth: number, + maxWidth: number, + letterSpacing: number, +): [string, string, number] { + if (maxWidth <= 0) { + return ['', word, 0]; + } + + const shouldStartFromBack = wordWidth - maxWidth < wordWidth / 2; + + if (!shouldStartFromBack) { + let currentWidth = wordWidth; + + for (let i = word.length - 1; i > 0; i--) { + currentWidth -= measure(font, word.charAt(i), letterSpacing); + + if (currentWidth <= maxWidth) { + return [word.substring(0, i), word.substring(i), currentWidth]; + } + } + + return ['', word, 0]; + } + + let currentWidth = 0; + + for (let i = 0; i < word.length; i++) { + const charWidth = measure(font, word.charAt(i), letterSpacing); + + if (currentWidth + charWidth > maxWidth) { + return [word.substring(0, i), word.substring(i), currentWidth]; + } + + currentWidth += charWidth; + } + + return [word, '', wordWidth]; +} + +function truncateWord( + font: FontMetrics, + word: string, + wordWidth: number, + maxWidth: number, + letterSpacing: number, + overflowSuffix: string, + overflowWidth: number, +): [string, string, number] { + const targetWidth = maxWidth - overflowWidth; + + if (targetWidth <= 0) { + return ['', word, 0]; + } + + const shouldStartFromBack = wordWidth - targetWidth < wordWidth / 2; + + if (!shouldStartFromBack) { + let currentWidth = wordWidth; + + for (let i = word.length - 1; i > 0; i--) { + currentWidth -= measure(font, word.charAt(i), letterSpacing); + + if (currentWidth <= targetWidth) { + return [ + word.substring(0, i) + overflowSuffix, + word.substring(i), + currentWidth + overflowWidth, + ]; + } + } + + return [overflowSuffix, word, overflowWidth]; + } + + let currentWidth = 0; + + for (let i = 0; i < word.length; i++) { + const charWidth = measure(font, word.charAt(i), letterSpacing); + + if (currentWidth + charWidth > targetWidth) { + return [ + word.substring(0, i) + overflowSuffix, + word.substring(i), + currentWidth + overflowWidth, + ]; + } + + currentWidth += charWidth; + } + + return [word + overflowSuffix, '', wordWidth + overflowWidth]; +} diff --git a/packages/plugin-flexbox/src/text/resolveAtlasUrl.test.ts b/packages/plugin-flexbox/src/text/resolveAtlasUrl.test.ts new file mode 100644 index 00000000..58b4de12 --- /dev/null +++ b/packages/plugin-flexbox/src/text/resolveAtlasUrl.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveAtlasUrl } from './resolveAtlasUrl'; + +describe('resolveAtlasUrl', () => { + const base = 'https://host.example/app/'; + + it('resolves a root-relative URL against the base origin', () => { + expect(resolveAtlasUrl('/fonts/inter/Regular.msdf.json', base)).toBe( + 'https://host.example/fonts/inter/Regular.msdf.json', + ); + }); + + it('resolves a relative URL against the base path', () => { + expect(resolveAtlasUrl('fonts/Regular.msdf.json', base)).toBe( + 'https://host.example/app/fonts/Regular.msdf.json', + ); + }); + + it('leaves an absolute http URL unchanged', () => { + const abs = 'https://cdn.example/fonts/Regular.msdf.json'; + expect(resolveAtlasUrl(abs, base)).toBe(abs); + }); + + it('leaves a data URL unchanged', () => { + const data = 'data:application/json,{}'; + expect(resolveAtlasUrl(data, base)).toBe(data); + }); + + it('returns the input unchanged when no base is available', () => { + expect(resolveAtlasUrl('/fonts/Regular.msdf.json', undefined)).toBe( + '/fonts/Regular.msdf.json', + ); + }); + + it('does not resolve against a blob base (the worker trap)', () => { + // A blob base can't resolve a root-relative path; the whole point is that + // we resolve on the main thread against the real document URL, never here. + const blob = 'blob:https://host.example/uuid'; + expect(resolveAtlasUrl('/fonts/Regular.msdf.json', blob)).toBe( + '/fonts/Regular.msdf.json', + ); + }); +}); diff --git a/packages/plugin-flexbox/src/text/resolveAtlasUrl.ts b/packages/plugin-flexbox/src/text/resolveAtlasUrl.ts new file mode 100644 index 00000000..f54c97d0 --- /dev/null +++ b/packages/plugin-flexbox/src/text/resolveAtlasUrl.ts @@ -0,0 +1,26 @@ +/** + * Resolve a font atlas URL against a base href on the main thread. + * + * The Yoga worker is bundled inline (`?worker&inline`), so in a production + * build its `self.location` is a `blob:` URL. A root-relative fetch like + * `/fonts/x.json` can't resolve against a blob base and throws. Resolving to an + * absolute URL here, before the URL crosses into the worker, sidesteps that. + * + * `baseHref` should be the main thread's document URL. A blob base can't + * resolve a relative path, so on failure (or no base) we hand back the input + * untouched. + */ +export function resolveAtlasUrl( + url: string, + baseHref: string | undefined, +): string { + if (!baseHref) { + return url; + } + + try { + return new URL(url, baseHref).href; + } catch { + return url; + } +} diff --git a/packages/plugin-flexbox/src/translatePercent.spec.ts b/packages/plugin-flexbox/src/translatePercent.spec.ts new file mode 100644 index 00000000..d35f7d72 --- /dev/null +++ b/packages/plugin-flexbox/src/translatePercent.spec.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from 'vitest'; + +import { YogaManager } from './YogaManager'; + +// Parse the 12-byte-per-node render buffer (id, x, y, w, h; little-endian). +function readNode(buffer: ArrayBuffer | undefined, id: number) { + if (!buffer) { + return undefined; + } + + const view = new DataView(buffer); + + for (let o = 0; o + 20 <= buffer.byteLength; o += 20) { + if (view.getUint32(o, true) === id) { + return { + x: view.getInt32(o + 4, true), + y: view.getInt32(o + 8, true), + w: view.getInt32(o + 12, true), + h: view.getInt32(o + 16, true), + }; + } + } + + return undefined; +} + +async function setup() { + const manager = new YogaManager(); + + await manager.init(); + manager.addNode(1); + manager.applyStyle(1, { w: 1000, h: 500, display: 'flex' }, true); + manager.addIndependentRoot(1); + manager.addNode(2); + manager.applyStyle(2, { w: 400, h: 80 }, true); + manager.addChildNode(1, 2); + + let last: ArrayBuffer | undefined; + + manager.on('render', (b) => { + if (b.byteLength > 0) { + last = b; + } + }); + + return { + manager, + flush: () => { + last = undefined; + manager.flushLayout(); + + return last; + }, + }; +} + +describe('percentage translate (own-size, RN semantics)', () => { + it('resolves translateX percent against the node own width', async () => { + const { manager, flush } = await setup(); + + manager.applyStyle(2, { transform: { translateX: '50%' } }, true); + + expect(readNode(flush(), 2)?.x).toBe(200); + }); + + it('resolves translateY percent against the node own height', async () => { + const { manager, flush } = await setup(); + + manager.applyStyle(2, { transform: { translateY: '50%' } }, true); + + expect(readNode(flush(), 2)?.y).toBe(40); + }); + + it('resolves a percent that arrives AFTER layout has settled', async () => { + // The reanimated path: mount layout runs (and is marked seen) before the + // animated style lands. A percent-only change never dirties yoga, so the + // readback must still visit and emit the node. + const { manager, flush } = await setup(); + + flush(); + manager.applyStyle(2, { transform: { translateX: '50%' } }, true); + + expect(readNode(flush(), 2)?.x).toBe(200); + }); + + it('re-resolves when the percentage changes', async () => { + const { manager, flush } = await setup(); + + manager.applyStyle(2, { transform: { translateX: '50%' } }, true); + flush(); + manager.applyStyle(2, { transform: { translateX: '75%' } }, true); + + expect(readNode(flush(), 2)?.x).toBe(300); + }); + + it('does not re-emit a settled percent node on unrelated passes', async () => { + const { manager, flush } = await setup(); + + manager.applyStyle(2, { transform: { translateX: '50%' } }, true); + flush(); + + // Nothing changed: the node must not spam updates (resized events). + expect(readNode(flush(), 2)).toBeUndefined(); + }); + + it('ignores a NaN percentage instead of moving the node', async () => { + const { manager, flush } = await setup(); + + flush(); + manager.applyStyle(2, { transform: { translateX: 'NaN%' as `${number}%` } }, true); + + expect(readNode(flush(), 2)).toBeUndefined(); + }); + + it('leaves pixel translate unchanged', async () => { + const { manager, flush } = await setup(); + + manager.applyStyle(2, { transform: { translateX: 50 } }, true); + + expect(readNode(flush(), 2)?.x).toBe(50); + }); +}); diff --git a/packages/plugin-flexbox/src/types/FlexStyles.ts b/packages/plugin-flexbox/src/types/FlexStyles.ts index 3bc5ebbb..b72cb054 100644 --- a/packages/plugin-flexbox/src/types/FlexStyles.ts +++ b/packages/plugin-flexbox/src/types/FlexStyles.ts @@ -21,8 +21,10 @@ export type JustifyContent = | 'space-evenly'; export type Transform = { - translateX?: number; - translateY?: number; + // A string is a percentage of the node's OWN size (RN semantics), resolved at + // layout readback. A number is pixels, baked into the yoga position directly. + translateX?: number | `${number}%`; + translateY?: number | `${number}%`; scaleX?: number; scaleY?: number; rotation?: number; @@ -53,7 +55,10 @@ export type FlexLightningBaseElementStyle = { paddingHorizontal?: DimensionValue; paddingVertical?: DimensionValue; - aspectRatio?: number; + // RN accepts a number (`1.5`), a ratio string (`'3/2'`), or a numeric string + // (`'1.5'`); Yoga only takes a number, so the string forms are parsed before + // being applied. + aspectRatio?: number | string; maxHeight?: number; maxWidth?: number; minHeight?: DimensionValue; @@ -72,6 +77,10 @@ export type FlexLightningBaseElementStyle = { right?: DimensionValue; /** Only affects flex layouts */ bottom?: DimensionValue; + /** Logical inline-start inset. LTR: same as `left`. */ + start?: DimensionValue; + /** Logical inline-end inset. LTR: same as `right`. */ + end?: DimensionValue; }; export interface FlexContainer { diff --git a/packages/plugin-flexbox/src/types/ManagerNode.ts b/packages/plugin-flexbox/src/types/ManagerNode.ts index 3be2f406..351bf094 100644 --- a/packages/plugin-flexbox/src/types/ManagerNode.ts +++ b/packages/plugin-flexbox/src/types/ManagerNode.ts @@ -1,9 +1,20 @@ import type { Node } from 'yoga-layout'; +import type { TextMeasureProps } from '../text/layoutText'; + export type ManagerNode = { id: number; parent?: ManagerNode; node: Node; children: ManagerNode[]; props: Record; + /** Set when this node is a measured text leaf (has a Yoga measure func). */ + text?: { + fontFamily: string; + props: TextMeasureProps; + }; + /** Percentage translate (of the node's own size), resolved at layout readback. */ + translatePercent?: { x?: number; y?: number }; + /** Last emitted resolved position for a percent node, to dedupe readback writes. */ + resolvedTranslate?: { left: number; top: number }; }; diff --git a/packages/plugin-flexbox/src/types/YogaOptions.ts b/packages/plugin-flexbox/src/types/YogaOptions.ts index 65d29444..ac171cea 100644 --- a/packages/plugin-flexbox/src/types/YogaOptions.ts +++ b/packages/plugin-flexbox/src/types/YogaOptions.ts @@ -1,6 +1,20 @@ +/** A font the worker can measure text with, by family name and atlas JSON URL. */ +export type YogaFont = { + fontFamily: string; + /** URL of the msdf atlas `.json` (same file the renderer loads). */ + atlasDataUrl: string; +}; + export type YogaOptions = { useWebDefaults?: boolean; useWebWorker?: boolean; + /** + * msdf fonts to load for synchronous text measurement. When provided, text + * leaves are measured by Yoga during layout (wrapping/sizing) instead of + * relying on the renderer's async measurement. Loaded in the background; + * text using a not-yet-loaded font measures empty until it arrives. + */ + fonts?: YogaFont[]; /** * Whether to expand flex basis to auto when expanding a flex value. The specs * say it should expand to 0, but this does not match react-native behaviour. diff --git a/packages/plugin-flexbox/src/util/applyReactPropsToYoga.border.spec.ts b/packages/plugin-flexbox/src/util/applyReactPropsToYoga.border.spec.ts new file mode 100644 index 00000000..ca2b0016 --- /dev/null +++ b/packages/plugin-flexbox/src/util/applyReactPropsToYoga.border.spec.ts @@ -0,0 +1,92 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import type { Node } from 'yoga-layout'; +import { loadYoga, type Yoga } from 'yoga-layout/load'; + +import type { LightningViewElementStyle } from '@plextv/react-lightning'; + +import type { YogaOptions } from '../types/YogaOptions'; +import { applyFlexPropToYoga } from './applyReactPropsToYoga'; + +// react-native feeds borderWidth into Yoga (border-box), so a border reserves +// layout space and content sits inside it. react-lightning painted the border +// but never told Yoga about it, so any component that adds a border on a state +// change (e.g. a selected tab) and compensates with `margin: -borderWidth` +// ended up shifting by the border width. These specs pin the border-box +// behaviour with real Yoga. + +const options = { expandToAutoFlexBasis: false } as YogaOptions; + +let yoga: Yoga; + +beforeAll(async () => { + yoga = await loadYoga(); +}); + +function apply(node: Node, style: Partial): void { + for (const key in style) { + applyFlexPropToYoga( + yoga, + options, + node, + // oxlint-disable-next-line typescript/no-explicit-any -- test helper + key as any, + style[key as keyof LightningViewElementStyle], + ); + } +} + +describe('applyFlexPropToYoga border', () => { + it('reserves the border width on every edge (object form)', () => { + const node = yoga.Node.create(); + + node.setWidth(100); + node.setHeight(40); + apply(node, { border: { w: 10, color: 0 } }); + node.calculateLayout(undefined, undefined, yoga.DIRECTION_LTR); + + expect(node.getComputedBorder(yoga.EDGE_LEFT)).toBe(10); + expect(node.getComputedBorder(yoga.EDGE_TOP)).toBe(10); + expect(node.getComputedBorder(yoga.EDGE_RIGHT)).toBe(10); + expect(node.getComputedBorder(yoga.EDGE_BOTTOM)).toBe(10); + }); + + it('reserves the border width on every edge (number form)', () => { + const node = yoga.Node.create(); + + apply(node, { border: 4 }); + node.calculateLayout(undefined, undefined, yoga.DIRECTION_LTR); + + expect(node.getComputedBorder(yoga.EDGE_LEFT)).toBe(4); + expect(node.getComputedBorder(yoga.EDGE_BOTTOM)).toBe(4); + }); + + // The tab case: an auto-sized box gains a border on select and pulls its + // content back out with `margin: -border`. With border-box that cancels + // exactly, so neither the box width nor the content position moves. + it('does not shift an auto-sized box when a -border margin compensates', () => { + function measure(border: number) { + const outer = yoga.Node.create(); + outer.setPadding(yoga.EDGE_HORIZONTAL, 8); + apply(outer, { border: { w: border, color: 0 } }); + + const child = yoga.Node.create(); + child.setWidth(50); + child.setHeight(20); + child.setMargin(yoga.EDGE_ALL, -border); + outer.insertChild(child, 0); + + outer.calculateLayout(undefined, undefined, yoga.DIRECTION_LTR); + + return { + width: outer.getComputedWidth(), + childLeft: child.getComputedLeft(), + }; + } + + const plain = measure(0); + const bordered = measure(2); + + expect(bordered.width).toBe(plain.width); + expect(bordered.childLeft).toBe(plain.childLeft); + }); +}); diff --git a/packages/plugin-flexbox/src/util/applyReactPropsToYoga.position.spec.ts b/packages/plugin-flexbox/src/util/applyReactPropsToYoga.position.spec.ts new file mode 100644 index 00000000..3f26cbfa --- /dev/null +++ b/packages/plugin-flexbox/src/util/applyReactPropsToYoga.position.spec.ts @@ -0,0 +1,66 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import type { Node } from 'yoga-layout'; +import { loadYoga, type Yoga } from 'yoga-layout/load'; + +import type { LightningViewElementStyle } from '@plextv/react-lightning'; + +import type { YogaOptions } from '../types/YogaOptions'; +import { applyFlexPropToYoga } from './applyReactPropsToYoga'; + +// RN ships logical start/end insets (LTR: start=left, end=right). react-lightning +// mapped logical margins/paddings but never the position insets, so an absolutely +// positioned box pinned with `end: 0` fell back to the left edge. These pin the +// logical-inset positioning with real Yoga. + +const options = { expandToAutoFlexBasis: false } as YogaOptions; + +let yoga: Yoga; + +beforeAll(async () => { + yoga = await loadYoga(); +}); + +function apply(node: Node, style: Partial): void { + for (const key in style) { + applyFlexPropToYoga( + yoga, + options, + node, + // oxlint-disable-next-line typescript/no-explicit-any -- test helper + key as any, + style[key as keyof LightningViewElementStyle], + ); + } +} + +function layoutChild(style: Partial): number { + const parent = yoga.Node.create(); + parent.setWidth(200); + parent.setHeight(100); + + const child = yoga.Node.create(); + child.setWidth(50); + child.setHeight(20); + apply(child, { position: 'absolute', ...style }); + parent.insertChild(child, 0); + + parent.calculateLayout(undefined, undefined, yoga.DIRECTION_LTR); + + return child.getComputedLeft(); +} + +describe('applyFlexPropToYoga logical position insets', () => { + it('pins `end: 0` to the right edge (LTR)', () => { + // parent 200 - child 50 - end 0 => left 150 + expect(layoutChild({ end: 0 })).toBe(150); + }); + + it('offsets `end` inward by its value', () => { + // parent 200 - child 50 - end 20 => left 130 + expect(layoutChild({ end: 20 })).toBe(130); + }); + + it('pins `start` to the left edge plus its value (LTR)', () => { + expect(layoutChild({ start: 30 })).toBe(30); + }); +}); diff --git a/packages/plugin-flexbox/src/util/applyReactPropsToYoga.ts b/packages/plugin-flexbox/src/util/applyReactPropsToYoga.ts index c0f4b4f4..1c8fcd47 100644 --- a/packages/plugin-flexbox/src/util/applyReactPropsToYoga.ts +++ b/packages/plugin-flexbox/src/util/applyReactPropsToYoga.ts @@ -15,7 +15,8 @@ import type { YogaOptions } from '../types'; import type { AutoDimensionValue, Transform } from '../types/FlexStyles'; import type { ManagerNode } from '../types/ManagerNode'; import type { FlexProps } from './isFlexStyleProp'; -import { isFlexStyleProp } from './isFlexStyleProp'; +import { flexProps, isFlexStyleProp } from './isFlexStyleProp'; +import { parseAspectRatio } from './parseAspectRatio'; import { parseFlexValue } from './parseFlexValue'; function mapDisplay(yoga: Yoga, value?: 'flex' | 'none'): Display { @@ -155,11 +156,184 @@ function applyFlex(node: Node, value?: string | number, expandToAutoFlexBasis = } } +function borderWidthOf(value: LightningViewElementStyle['border']): number { + if (value == null) { + return 0; + } + + return typeof value === 'number' ? value : (value.w ?? 0); +} + +// w/h are also driven by texture loads and VirtualList pinning, so resetting +// them would clobber that. flex has no single safe default (shorthand over grow/shrink/basis). +const RESETTABLE_FLEX_PROPS: ReadonlySet = new Set( + (Object.keys(flexProps) as FlexProps[]).filter( + (prop) => prop !== 'w' && prop !== 'h' && prop !== 'flex', + ), +); + +/** Restores a single yoga prop to its layout default (mirrors each `mapX(yoga)` no-value case). */ +function resetFlexPropToDefault(yoga: Yoga, node: Node, prop: FlexProps): void { + switch (prop) { + case 'display': + node.setDisplay(mapDisplay(yoga)); + return; + case 'flexDirection': + node.setFlexDirection(mapDirection(yoga)); + return; + case 'alignItems': + node.setAlignItems(mapAlignItems(yoga)); + return; + case 'alignSelf': + node.setAlignSelf(mapAlignItems(yoga)); + return; + case 'alignContent': + node.setAlignContent(mapAlignContent(yoga)); + return; + case 'justifyContent': + node.setJustifyContent(mapJustify(yoga)); + return; + case 'flexWrap': + node.setFlexWrap(mapWrap(yoga)); + return; + case 'position': + node.setPositionType(mapPosition(yoga)); + return; + case 'flexGrow': + node.setFlexGrow(undefined); + return; + case 'flexShrink': + node.setFlexShrink(undefined); + return; + case 'flexBasis': + node.setFlexBasisAuto(); + return; + case 'aspectRatio': + node.setAspectRatio(undefined); + return; + case 'gap': + node.setGap(yoga.GUTTER_ALL, undefined); + return; + case 'rowGap': + node.setGap(yoga.GUTTER_ROW, undefined); + return; + case 'columnGap': + node.setGap(yoga.GUTTER_COLUMN, undefined); + return; + case 'minWidth': + node.setMinWidth(undefined); + return; + case 'minHeight': + node.setMinHeight(undefined); + return; + case 'maxWidth': + node.setMaxWidth(undefined); + return; + case 'maxHeight': + node.setMaxHeight(undefined); + return; + case 'margin': + node.setMargin(yoga.EDGE_ALL, undefined); + return; + case 'marginTop': + node.setMargin(yoga.EDGE_TOP, undefined); + return; + case 'marginBottom': + node.setMargin(yoga.EDGE_BOTTOM, undefined); + return; + case 'marginLeft': + node.setMargin(yoga.EDGE_LEFT, undefined); + return; + case 'marginRight': + node.setMargin(yoga.EDGE_RIGHT, undefined); + return; + case 'marginStart': + node.setMargin(yoga.EDGE_START, undefined); + return; + case 'marginEnd': + node.setMargin(yoga.EDGE_END, undefined); + return; + case 'marginHorizontal': + case 'marginInline': + node.setMargin(yoga.EDGE_HORIZONTAL, undefined); + return; + case 'marginVertical': + case 'marginBlock': + node.setMargin(yoga.EDGE_VERTICAL, undefined); + return; + case 'padding': + node.setPadding(yoga.EDGE_ALL, undefined); + return; + case 'paddingTop': + node.setPadding(yoga.EDGE_TOP, undefined); + return; + case 'paddingBottom': + node.setPadding(yoga.EDGE_BOTTOM, undefined); + return; + case 'paddingLeft': + node.setPadding(yoga.EDGE_LEFT, undefined); + return; + case 'paddingRight': + node.setPadding(yoga.EDGE_RIGHT, undefined); + return; + case 'paddingStart': + node.setPadding(yoga.EDGE_START, undefined); + return; + case 'paddingEnd': + node.setPadding(yoga.EDGE_END, undefined); + return; + case 'paddingHorizontal': + case 'paddingInline': + node.setPadding(yoga.EDGE_HORIZONTAL, undefined); + return; + case 'paddingVertical': + case 'paddingBlock': + node.setPadding(yoga.EDGE_VERTICAL, undefined); + return; + case 'border': + node.setBorder(yoga.EDGE_ALL, undefined); + return; + case 'borderTop': + node.setBorder(yoga.EDGE_TOP, undefined); + return; + case 'borderRight': + node.setBorder(yoga.EDGE_RIGHT, undefined); + return; + case 'borderBottom': + node.setBorder(yoga.EDGE_BOTTOM, undefined); + return; + case 'borderLeft': + node.setBorder(yoga.EDGE_LEFT, undefined); + return; + case 'top': + node.setPosition(yoga.EDGE_TOP, undefined); + return; + case 'left': + node.setPosition(yoga.EDGE_LEFT, undefined); + return; + case 'right': + node.setPosition(yoga.EDGE_RIGHT, undefined); + return; + case 'bottom': + node.setPosition(yoga.EDGE_BOTTOM, undefined); + return; + case 'start': + node.setPosition(yoga.EDGE_START, undefined); + return; + case 'end': + node.setPosition(yoga.EDGE_END, undefined); + return; + default: + return; + } +} + export default function applyReactPropsToYoga( yoga: Yoga, config: YogaOptions, managerNode: ManagerNode, style: Partial, + resetMissing = false, ): void { // `for...in` instead of `Object.entries(style)` to avoid the per-call // array allocation. This function runs on every applyStyle dispatch, @@ -181,6 +355,19 @@ export default function applyReactPropsToYoga( } } } + // Only safe for a "full style" caller (see plugin transformProps): there, a + // key missing from `style` means removed, not just untouched this call. + if (resetMissing) { + for (const prop in managerNode.props) { + if ( + RESETTABLE_FLEX_PROPS.has(prop as FlexProps) && + style[prop as keyof LightningViewElementStyle] == null + ) { + delete managerNode.props[prop]; + resetFlexPropToDefault(yoga, managerNode.node, prop as FlexProps); + } + } + } } export function applyFlexPropToYoga( @@ -195,7 +382,10 @@ export function applyFlexPropToYoga( } try { - const value = styleValue as Exclude; + const value = styleValue as Exclude< + LightningViewElementStyle[K], + Transform | { w: number; color: number } + >; switch (key) { case 'display': @@ -219,9 +409,17 @@ export function applyFlexPropToYoga( case 'maxHeight': node.setMaxHeight(formatSizeValue<'maxHeight'>(value)); return true; - case 'aspectRatio': - node.setAspectRatio(value as LightningViewElementStyle['aspectRatio']); + case 'aspectRatio': { + const ratio = parseAspectRatio( + value as NonNullable, + ); + + if (ratio != null) { + node.setAspectRatio(ratio); + } + return true; + } case 'margin': node.setMargin(yoga.EDGE_ALL, value as LightningViewElementStyle['margin']); return true; @@ -280,6 +478,24 @@ export function applyFlexPropToYoga( case 'paddingBlock': node.setPadding(yoga.EDGE_VERTICAL, value as LightningViewElementStyle['paddingBlock']); return true; + case 'border': + node.setBorder( + yoga.EDGE_ALL, + borderWidthOf(styleValue as LightningViewElementStyle['border']), + ); + return true; + case 'borderTop': + node.setBorder(yoga.EDGE_TOP, (value as number) ?? 0); + return true; + case 'borderRight': + node.setBorder(yoga.EDGE_RIGHT, (value as number) ?? 0); + return true; + case 'borderBottom': + node.setBorder(yoga.EDGE_BOTTOM, (value as number) ?? 0); + return true; + case 'borderLeft': + node.setBorder(yoga.EDGE_LEFT, (value as number) ?? 0); + return true; case 'flex': applyFlex(node, value, config.expandToAutoFlexBasis); return true; @@ -334,6 +550,12 @@ export function applyFlexPropToYoga( case 'top': node.setPosition(yoga.EDGE_TOP, (value as LightningViewElementStyle['top']) ?? 0); return true; + case 'start': + node.setPosition(yoga.EDGE_START, (value as LightningViewElementStyle['left']) ?? 0); + return true; + case 'end': + node.setPosition(yoga.EDGE_END, (value as LightningViewElementStyle['right']) ?? 0); + return true; } } catch (err) { console.error(err); diff --git a/packages/plugin-flexbox/src/util/isFlexStyleProp.ts b/packages/plugin-flexbox/src/util/isFlexStyleProp.ts index 7bb83dfb..36001c8b 100644 --- a/packages/plugin-flexbox/src/util/isFlexStyleProp.ts +++ b/packages/plugin-flexbox/src/util/isFlexStyleProp.ts @@ -53,6 +53,14 @@ export const flexProps = { left: true, right: true, bottom: true, + start: true, + end: true, + + border: true, + borderTop: true, + borderRight: true, + borderBottom: true, + borderLeft: true, } as const; flexProps satisfies Partial>; diff --git a/packages/plugin-flexbox/src/util/parseAspectRatio.spec.ts b/packages/plugin-flexbox/src/util/parseAspectRatio.spec.ts new file mode 100644 index 00000000..81791643 --- /dev/null +++ b/packages/plugin-flexbox/src/util/parseAspectRatio.spec.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; + +import { parseAspectRatio } from './parseAspectRatio'; + +describe('parseAspectRatio', () => { + it('passes through a positive finite number', () => { + expect(parseAspectRatio(1.5)).toBe(1.5); + }); + + it('parses a ratio string into a number', () => { + expect(parseAspectRatio('3/2')).toBe(1.5); + expect(parseAspectRatio('16/9')).toBeCloseTo(16 / 9); + }); + + it('parses a plain numeric string', () => { + expect(parseAspectRatio('1.5')).toBe(1.5); + }); + + it('returns undefined for non-positive or non-finite values', () => { + expect(parseAspectRatio(0)).toBeUndefined(); + expect(parseAspectRatio(-2)).toBeUndefined(); + expect(parseAspectRatio(Number.NaN)).toBeUndefined(); + }); + + it('returns undefined for malformed strings', () => { + expect(parseAspectRatio('abc')).toBeUndefined(); + expect(parseAspectRatio('3/0')).toBeUndefined(); + expect(parseAspectRatio('/2')).toBeUndefined(); + }); +}); diff --git a/packages/plugin-flexbox/src/util/parseAspectRatio.ts b/packages/plugin-flexbox/src/util/parseAspectRatio.ts new file mode 100644 index 00000000..435be59b --- /dev/null +++ b/packages/plugin-flexbox/src/util/parseAspectRatio.ts @@ -0,0 +1,33 @@ +/** + * Normalizes a React Native `aspectRatio` value to the plain number Yoga + * expects. RN accepts a number (`1.5`), a ratio string (`'3/2'`), or a numeric + * string (`'1.5'`); Yoga's `setAspectRatio` only takes a number, so passing a + * string straight through yields `NaN` and the ratio is silently dropped — + * leaving e.g. an image sized only by `aspectRatio` + a height with no width, + * so it never paints. + * + * Returns `undefined` for values that don't describe a positive, finite ratio, + * so callers can skip applying it rather than feeding Yoga a bad number. + */ +export function parseAspectRatio(value: number | string): number | undefined { + if (typeof value === 'number') { + return Number.isFinite(value) && value > 0 ? value : undefined; + } + + const slash = value.indexOf('/'); + + if (slash !== -1) { + const width = Number.parseFloat(value.slice(0, slash)); + const height = Number.parseFloat(value.slice(slash + 1)); + + if (Number.isFinite(width) && Number.isFinite(height) && height > 0 && width > 0) { + return width / height; + } + + return undefined; + } + + const ratio = Number.parseFloat(value); + + return Number.isFinite(ratio) && ratio > 0 ? ratio : undefined; +} diff --git a/packages/plugin-flexbox/src/util/resolveTranslateInset.test.ts b/packages/plugin-flexbox/src/util/resolveTranslateInset.test.ts new file mode 100644 index 00000000..7d3b78b8 --- /dev/null +++ b/packages/plugin-flexbox/src/util/resolveTranslateInset.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; + +import { + resolveHorizontalTranslate, + resolveVerticalTranslate, +} from './resolveTranslateInset'; + +describe('resolveTranslateInset', () => { + it('left-anchored translateX offsets the left edge (base + translate)', () => { + expect(resolveHorizontalTranslate(false, 0, 0, 40)).toEqual({ edge: 'left', value: 40 }); + expect(resolveHorizontalTranslate(false, 10, 0, 40)).toEqual({ edge: 'left', value: 50 }); + }); + + it('right-anchored translateX offsets the right edge (base - translate)', () => { + // docked at right:32; translateX 0 must keep it docked (not snap to left) + expect(resolveHorizontalTranslate(true, 0, 32, 0)).toEqual({ edge: 'right', value: 32 }); + // sliding in from the right (+translate) pushes the right inset negative + expect(resolveHorizontalTranslate(true, 0, 32, 100)).toEqual({ edge: 'right', value: -68 }); + }); + + it('bottom-anchored translateY offsets the bottom edge (base - translate)', () => { + expect(resolveVerticalTranslate(true, 0, 32, 0)).toEqual({ edge: 'bottom', value: 32 }); + expect(resolveVerticalTranslate(true, 0, 32, 100)).toEqual({ edge: 'bottom', value: -68 }); + }); + + it('top-anchored translateY offsets the top edge (base + translate)', () => { + expect(resolveVerticalTranslate(false, 0, 0, 40)).toEqual({ edge: 'top', value: 40 }); + expect(resolveVerticalTranslate(false, 5, 0, 40)).toEqual({ edge: 'top', value: 45 }); + }); +}); diff --git a/packages/plugin-flexbox/src/util/resolveTranslateInset.ts b/packages/plugin-flexbox/src/util/resolveTranslateInset.ts new file mode 100644 index 00000000..31cc8188 --- /dev/null +++ b/packages/plugin-flexbox/src/util/resolveTranslateInset.ts @@ -0,0 +1,28 @@ +export type HorizontalInset = { edge: 'left' | 'right'; value: number }; +export type VerticalInset = { edge: 'top' | 'bottom'; value: number }; + +// translateX/Y shift a node's laid-out position by writing a yoga inset. A node +// anchored via `right`/`bottom` must translate that same edge: writing the +// opposite edge over-constrains yoga (left+width wins over right) and snaps the +// node across the container. +export function resolveHorizontalTranslate( + isRightAnchored: boolean, + baseLeft: number, + baseRight: number, + translateX: number, +): HorizontalInset { + return isRightAnchored + ? { edge: 'right', value: baseRight - translateX } + : { edge: 'left', value: baseLeft + translateX }; +} + +export function resolveVerticalTranslate( + isBottomAnchored: boolean, + baseTop: number, + baseBottom: number, + translateY: number, +): VerticalInset { + return isBottomAnchored + ? { edge: 'bottom', value: baseBottom - translateY } + : { edge: 'top', value: baseTop + translateY }; +} diff --git a/packages/plugin-flexbox/src/worker.ts b/packages/plugin-flexbox/src/worker.ts index 078d15c4..587de32d 100644 --- a/packages/plugin-flexbox/src/worker.ts +++ b/packages/plugin-flexbox/src/worker.ts @@ -102,6 +102,7 @@ self.onmessage = async ( manager.applyStyles( args?.[1] as Record>, args?.[2] as boolean, + args?.[3] as Record | undefined, ); break; } diff --git a/packages/plugin-reanimated/src/animation/AnimatedValue.ts b/packages/plugin-reanimated/src/animation/AnimatedValue.ts index 1678c992..b3c1bc54 100644 --- a/packages/plugin-reanimated/src/animation/AnimatedValue.ts +++ b/packages/plugin-reanimated/src/animation/AnimatedValue.ts @@ -7,6 +7,12 @@ import type { } from 'react-native-reanimated-original'; import { AnimationType } from '../types/AnimationType'; +import { + type AnimationProgram, + firstLeaf, + leafProgram, + restingValue, +} from './animationProgram'; import { createSpringAnimation } from './spring'; import { createTimingAnimation } from './timing'; @@ -20,6 +26,10 @@ export class AnimatedValue { public value: AnimatableValue; public lngAnimation: AnimationSettings; public callback?: AnimationCallback; + // Set once withSequence/withRepeat(sequence)/withDelay compose steps. A plain + // withTiming/withSpring leaves it undefined and takes the direct transition + // path; a program is played step-by-step against the node instead. + public program?: AnimationProgram; public constructor( type: TType, @@ -33,6 +43,26 @@ export class AnimatedValue { this.callback = callback; } + public static fromProgram(program: AnimationProgram): AnimatedValue { + const value = new AnimatedValue(AnimationType.Timing, restingValue(program) ?? 0); + const first = firstLeaf(program); + + if (first) { + value.lngAnimation = first.lngAnimation; + } + + value.program = program; + + return value; + } + + public toProgram(): AnimationProgram { + return ( + this.program ?? + leafProgram({ toValue: this.value, lngAnimation: this.lngAnimation }) + ); + } + private _getLightningAnimationSettings(config?: AnimationConfigType[TType]): AnimationSettings { switch (this.type) { case AnimationType.Spring: diff --git a/packages/plugin-reanimated/src/animation/animationProgram.test.ts b/packages/plugin-reanimated/src/animation/animationProgram.test.ts new file mode 100644 index 00000000..b5a6f545 --- /dev/null +++ b/packages/plugin-reanimated/src/animation/animationProgram.test.ts @@ -0,0 +1,99 @@ +import type { AnimationSettings } from '@lightningjs/renderer'; +import { describe, expect, it } from 'vitest'; + +import { + type AnimationProgram, + delayProgram, + firstLeaf, + leafProgram, + repeatProgram, + mapProgram, + restingValue, + sequenceProgram, +} from './animationProgram'; + +const settings = (over: Partial = {}): AnimationSettings => ({ + duration: 100, + easing: 'linear', + delay: 0, + loop: false, + repeat: 0, + stopMethod: false, + ...over, +}); + +const leaf = (toValue: number, over?: Partial): AnimationProgram => + leafProgram({ toValue, lngAnimation: settings(over) }); + +describe('animationProgram', () => { + it('wraps a single step as a leaf', () => { + const p = leaf(10); + + expect(p).toEqual({ + kind: 'leaf', + leaf: { toValue: 10, lngAnimation: settings() }, + }); + }); + + it('builds a sequence preserving child order', () => { + const p = sequenceProgram([leaf(1), leaf(2), leaf(3)]); + + expect(p.kind).toBe('sequence'); + expect((p as { children: AnimationProgram[] }).children.map(restingValue)).toEqual([1, 2, 3]); + }); + + it('resting value is the last leaf of a sequence', () => { + expect(restingValue(sequenceProgram([leaf(1), leaf(2), leaf(3)]))).toBe(3); + }); + + it('first leaf is the first leaf of a sequence', () => { + const p = sequenceProgram([leaf(7), leaf(8)]); + + expect(firstLeaf(p)?.toValue).toBe(7); + }); + + it('wraps a child in a repeat with count and reverse', () => { + const seq = sequenceProgram([leaf(1), leaf(2)]); + const p = repeatProgram(seq, -1, false); + + expect(p).toEqual({ kind: 'repeat', child: seq, count: -1, reverse: false }); + }); + + it('resting value of a repeat is its child resting value', () => { + expect(restingValue(repeatProgram(sequenceProgram([leaf(1), leaf(2)]), 3, false))).toBe(2); + }); + + it('delay sets the delay on the first leaf only, without mutating the source', () => { + const inner = settings(); + const p = sequenceProgram([leafProgram({ toValue: 5, lngAnimation: inner }), leaf(6)]); + const delayed = delayProgram(p, 1000); + + expect(firstLeaf(delayed)?.lngAnimation.delay).toBe(1000); + expect(firstLeaf(delayed)?.toValue).toBe(5); + // source untouched + expect(inner.delay).toBe(0); + // later leaves keep their delay + expect(restingValue(delayed)).toBe(6); + }); + + it('nested sequence resolves first/resting through the tree', () => { + const p = sequenceProgram([ + leaf(1), + repeatProgram(sequenceProgram([leaf(2), leaf(3)]), -1, false), + ]); + + expect(firstLeaf(p)?.toValue).toBe(1); + expect(restingValue(p)).toBe(3); + }); + it('mapProgram maps every leaf target and keeps the tree shape', () => { + const p = sequenceProgram([ + leaf(1), + repeatProgram(sequenceProgram([leaf(2), leaf(3)]), -1, false), + ]); + const mapped = mapProgram(p, (v) => (v as number) * 10); + + expect(firstLeaf(mapped)?.toValue).toBe(10); + expect(restingValue(mapped)).toBe(30); + expect(mapped.kind).toBe('sequence'); + }); +}); diff --git a/packages/plugin-reanimated/src/animation/animationProgram.ts b/packages/plugin-reanimated/src/animation/animationProgram.ts new file mode 100644 index 00000000..ddd1baea --- /dev/null +++ b/packages/plugin-reanimated/src/animation/animationProgram.ts @@ -0,0 +1,101 @@ +import type { AnimationSettings } from '@lightningjs/renderer'; +import type { AnimatableValue } from 'react-native-reanimated-original'; + +export type ProgramLeaf = { + toValue: AnimatableValue; + lngAnimation: AnimationSettings; +}; + +// A program is the composition tree for withSequence / withRepeat / withDelay. +// A single withTiming/withSpring stays off this path (see AnimatedValue); the +// tree only exists once steps are chained. delay folds onto the first leaf. +export type AnimationProgram = + | { kind: 'leaf'; leaf: ProgramLeaf } + | { kind: 'sequence'; children: AnimationProgram[] } + | { kind: 'repeat'; child: AnimationProgram; count: number; reverse: boolean }; + +export function leafProgram(leaf: ProgramLeaf): AnimationProgram { + return { kind: 'leaf', leaf }; +} + +export function sequenceProgram(children: AnimationProgram[]): AnimationProgram { + return { kind: 'sequence', children }; +} + +export function repeatProgram( + child: AnimationProgram, + count: number, + reverse: boolean, +): AnimationProgram { + return { kind: 'repeat', child, count, reverse }; +} + +// Prepend a delay by overriding the first leaf's delay (clones so a cached +// lngAnimation, e.g. spring's, is never mutated). +export function delayProgram(program: AnimationProgram, delayMs: number): AnimationProgram { + switch (program.kind) { + case 'leaf': + return leafProgram({ + toValue: program.leaf.toValue, + lngAnimation: { ...program.leaf.lngAnimation, delay: delayMs }, + }); + case 'sequence': { + const [head, ...rest] = program.children; + + if (!head) { + return program; + } + + return sequenceProgram([delayProgram(head, delayMs), ...rest]); + } + case 'repeat': + return repeatProgram(delayProgram(program.child, delayMs), program.count, program.reverse); + } +} + +export function firstLeaf(program: AnimationProgram): ProgramLeaf | undefined { + switch (program.kind) { + case 'leaf': + return program.leaf; + case 'sequence': { + const first = program.children[0]; + + return first ? firstLeaf(first) : undefined; + } + case 'repeat': + return firstLeaf(program.child); + } +} + +export function restingValue(program: AnimationProgram): AnimatableValue | undefined { + switch (program.kind) { + case 'leaf': + return program.leaf.toValue; + case 'sequence': { + const last = program.children[program.children.length - 1]; + + return last ? restingValue(last) : undefined; + } + case 'repeat': + return restingValue(program.child); + } +} + +// Map every leaf target through fn (e.g. translateX px stays as the x value), +// keeping the tree shape and each leaf's animation settings. +export function mapProgram( + program: AnimationProgram, + fn: (value: AnimatableValue) => AnimatableValue, +): AnimationProgram { + switch (program.kind) { + case 'leaf': + return leafProgram({ + toValue: fn(program.leaf.toValue), + lngAnimation: program.leaf.lngAnimation, + }); + case 'sequence': + return sequenceProgram(program.children.map((child) => mapProgram(child, fn))); + case 'repeat': + return repeatProgram(mapProgram(program.child, fn), program.count, program.reverse); + } +} diff --git a/packages/plugin-reanimated/src/animation/resolveTimingEasing.test.ts b/packages/plugin-reanimated/src/animation/resolveTimingEasing.test.ts new file mode 100644 index 00000000..db953480 --- /dev/null +++ b/packages/plugin-reanimated/src/animation/resolveTimingEasing.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveTimingEasing } from './resolveTimingEasing'; + +describe('resolveTimingEasing', () => { + it('passes a function easing through unchanged', () => { + const fn = (t: number) => t * t; + + expect(resolveTimingEasing(fn)).toBe(fn); + }); + + it('resolves an Easing.bezier factory object to its function', () => { + const produced = (t: number) => t; + const factoryObj = { factory: () => produced }; + + expect(resolveTimingEasing(factoryObj)).toBe(produced); + }); + + it('falls back to linear when easing is missing', () => { + expect(resolveTimingEasing(undefined)).toBe('linear'); + }); + + it('falls back to linear for an unrecognized easing value', () => { + expect(resolveTimingEasing({ nope: true })).toBe('linear'); + expect(resolveTimingEasing('ease-in')).toBe('linear'); + }); +}); diff --git a/packages/plugin-reanimated/src/animation/resolveTimingEasing.ts b/packages/plugin-reanimated/src/animation/resolveTimingEasing.ts new file mode 100644 index 00000000..c7888d8d --- /dev/null +++ b/packages/plugin-reanimated/src/animation/resolveTimingEasing.ts @@ -0,0 +1,27 @@ +import type { AnimationSettings } from '@lightningjs/renderer'; + +type EasingFactory = { factory: () => AnimationSettings['easing'] }; + +function hasFactory(value: unknown): value is EasingFactory { + return ( + value != null && + typeof value === 'object' && + typeof (value as EasingFactory).factory === 'function' + ); +} + +// reanimated Easing.* are functions; Easing.bezier(...) returns a { factory } +// object. The renderer's CoreAnimation takes a function easing directly and +// resolves a string via getTimingFunction, so pass functions through, unwrap +// the factory, and fall back to linear for anything else. +export function resolveTimingEasing(easing: unknown): AnimationSettings['easing'] { + if (typeof easing === 'function') { + return easing as AnimationSettings['easing']; + } + + if (hasFactory(easing)) { + return easing.factory(); + } + + return 'linear'; +} diff --git a/packages/plugin-reanimated/src/animation/runAnimationProgram.test.ts b/packages/plugin-reanimated/src/animation/runAnimationProgram.test.ts new file mode 100644 index 00000000..896d8846 --- /dev/null +++ b/packages/plugin-reanimated/src/animation/runAnimationProgram.test.ts @@ -0,0 +1,98 @@ +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 { runAnimationProgram } from './runAnimationProgram'; + +const settings = ( + over: Partial = {}, +): AnimationSettings => ({ + duration: 10, + easing: 'linear', + delay: 0, + loop: false, + repeat: 0, + stopMethod: false, + ...over, +}); + +// A fake node whose animateStyle reads instance state through `this`, so an +// unbound call (this === undefined) throws instead of animating — the exact +// failure that froze every composed-program consumer (marquee, now-marker). +function makeView() { + const view = { + recycled: false, + props: { transition: {} as Record }, + applied: [] as { key: PropertyKey; value: unknown }[], + setProps(next: { transition?: Record }) { + Object.assign(this.props.transition, next.transition); + }, + animateStyle(key: PropertyKey, value: unknown) { + // Reading `this.applied` blows up when animateStyle is called unbound. + this.applied.push({ key, value }); + return { + waitUntilStopped: () => Promise.resolve(), + stop() {}, + }; + }, + }; + + return view; +} + +const flush = async () => { + for (let i = 0; i < 20; i++) { + await Promise.resolve(); + } +}; + +describe('runAnimationProgram', () => { + it('animates a single leaf against the node (method stays bound to the view)', async () => { + const view = makeView(); + + runAnimationProgram( + view as unknown as LightningElement, + 'x', + leafProgram({ toValue: -840, lngAnimation: settings() }), + ); + await flush(); + + expect(view.applied).toEqual([{ key: 'x', value: -840 }]); + expect(view.props.transition.x).toEqual(settings()); + }); + + it('plays sequence steps in order', async () => { + const view = makeView(); + + runAnimationProgram( + view as unknown as LightningElement, + 'x', + sequenceProgram([ + leafProgram({ toValue: -840, lngAnimation: settings() }), + leafProgram({ toValue: 300, lngAnimation: settings({ duration: 0 }) }), + leafProgram({ toValue: 0, lngAnimation: settings() }), + ]), + ); + await flush(); + + expect(view.applied.map((a) => a.value)).toEqual([-840, 300, 0]); + }); + + it('cancel stops advancing the program', async () => { + const view = makeView(); + + const cancel = runAnimationProgram( + view as unknown as LightningElement, + 'x', + sequenceProgram([ + leafProgram({ toValue: 1, lngAnimation: settings() }), + leafProgram({ toValue: 2, lngAnimation: settings() }), + ]), + ); + cancel(); + await flush(); + + // Cancelled before the first leaf resolved, so no further steps run. + expect(view.applied.length).toBeLessThanOrEqual(1); + }); +}); diff --git a/packages/plugin-reanimated/src/animation/runAnimationProgram.ts b/packages/plugin-reanimated/src/animation/runAnimationProgram.ts new file mode 100644 index 00000000..50b3ff73 --- /dev/null +++ b/packages/plugin-reanimated/src/animation/runAnimationProgram.ts @@ -0,0 +1,89 @@ +import type { IAnimationController } from '@lightningjs/renderer'; + +import type { LightningElement, LightningElementStyle } from '@plextv/react-lightning'; + +import type { AnimationProgram, ProgramLeaf } from './animationProgram'; + +export type CancelAnimation = () => void; + +// Play a composed program against one node prop: register each step's transition, +// animate to its target, wait for the node to report it stopped, then advance. +// Sequences chain, repeats loop (count < 0 = forever). Reverse isn't needed by +// any current consumer, so it plays forward. +export function runAnimationProgram( + view: LightningElement, + prop: keyof LightningElementStyle, + program: AnimationProgram, +): CancelAnimation { + let cancelled = false; + let current: IAnimationController | undefined; + + const playLeaf = async (leaf: ProgramLeaf): Promise => { + if (cancelled || view.recycled) { + return; + } + + try { + view.setProps({ transition: { [prop]: leaf.lngAnimation } } as never); + + // Call as a method: extracting animateStyle drops `this`, so it throws + // and the catch below silently freezes the whole composed animation. + const animatableView = view as unknown as { + animateStyle: ( + key: keyof LightningElementStyle, + value: unknown, + ) => IAnimationController; + }; + const controller = animatableView.animateStyle(prop, leaf.toValue); + + current = controller; + + await controller.waitUntilStopped(); + } catch { + // node was destroyed or recycled mid-flight; stop quietly + cancelled = true; + } + }; + + const play = async (node: AnimationProgram): Promise => { + if (cancelled) { + return; + } + + switch (node.kind) { + case 'leaf': + await playLeaf(node.leaf); + break; + case 'sequence': + for (const child of node.children) { + if (cancelled) { + break; + } + + await play(child); + } + break; + case 'repeat': { + const infinite = node.count < 0; + + for (let i = 0; (infinite || i < node.count) && !cancelled; i++) { + await play(node.child); + } + + break; + } + } + }; + + void play(program); + + return () => { + cancelled = true; + + try { + current?.stop(); + } catch { + // ignore + } + }; +} diff --git a/packages/plugin-reanimated/src/animation/timing.ts b/packages/plugin-reanimated/src/animation/timing.ts index cabfcb19..61b1558b 100644 --- a/packages/plugin-reanimated/src/animation/timing.ts +++ b/packages/plugin-reanimated/src/animation/timing.ts @@ -1,17 +1,16 @@ import type { AnimationSettings } from '@lightningjs/renderer'; import type { WithTimingConfig } from 'react-native-reanimated-original'; -import { ReduceMotion } from 'react-native-reanimated-original'; + +import { resolveTimingEasing } from './resolveTimingEasing'; const DefaultTimingConfig = { duration: 300, - easing: (t: number) => t, - reduceMotion: ReduceMotion.System, }; export function createTimingAnimation(config?: WithTimingConfig): AnimationSettings { return { duration: config?.duration ?? DefaultTimingConfig.duration, - easing: 'linear', + easing: resolveTimingEasing(config?.easing), delay: config?.delay ?? 0, loop: false, repeat: 0, diff --git a/packages/plugin-reanimated/src/exports/createAnimatedComponent.tsx b/packages/plugin-reanimated/src/exports/createAnimatedComponent.tsx index bafa3a96..82bab72f 100644 --- a/packages/plugin-reanimated/src/exports/createAnimatedComponent.tsx +++ b/packages/plugin-reanimated/src/exports/createAnimatedComponent.tsx @@ -13,6 +13,7 @@ import type { LayoutAnimationFunction, } from 'react-native-reanimated-original'; +import { PARTIAL_STYLE } from '@plextv/react-lightning'; import type { LightningElement, LightningElementProps, @@ -240,6 +241,9 @@ export function createAnimatedComponent( } animatedStyle.viewsRef.add(newRef); + // A fresh node has none of the style's resting values (listeners only + // push on change), so apply the current value now. + animatedStyle.applyToView?.(newRef); } this._ref = newRef; @@ -256,6 +260,7 @@ export function createAnimatedComponent( for (const newAnimatedStyle of newAnimatedStyles) { newAnimatedStyle.viewsRef.add(this._ref); + newAnimatedStyle.applyToView?.(this._ref); } } @@ -303,6 +308,9 @@ export function createAnimatedComponent( el.setNodeProp(key as keyof RendererNode, value, false); } + // Layout-animation styles are partial too; keep static flex props. + (lightningAnimation.style as Record)[PARTIAL_STYLE] = true; + el?.setProps({ style: lightningAnimation.style as LightningViewElementStyle, transition: lightningAnimation.transition, diff --git a/packages/plugin-reanimated/src/exports/useAnimatedReaction.ts b/packages/plugin-reanimated/src/exports/useAnimatedReaction.ts new file mode 100644 index 00000000..6065a531 --- /dev/null +++ b/packages/plugin-reanimated/src/exports/useAnimatedReaction.ts @@ -0,0 +1,21 @@ +import type { DependencyList } from 'react'; +import { useRef } from 'react'; + +import { useTrackedReaction } from './useTrackedReaction'; + +export function useAnimatedReaction( + prepare: () => PreparedResult, + react: (prepared: PreparedResult, previous: PreparedResult | null) => void, + dependencies?: DependencyList, +): void { + const previousRef = useRef(null); + + useTrackedReaction( + prepare, + (result) => { + react(result, previousRef.current); + previousRef.current = result; + }, + dependencies, + ); +} diff --git a/packages/plugin-reanimated/src/exports/useAnimatedStyle.ts b/packages/plugin-reanimated/src/exports/useAnimatedStyle.ts index 08bf7288..fe0d09c4 100644 --- a/packages/plugin-reanimated/src/exports/useAnimatedStyle.ts +++ b/packages/plugin-reanimated/src/exports/useAnimatedStyle.ts @@ -4,29 +4,77 @@ import type { useAnimatedStyle as useAnimatedStyleRN } from 'react-native-reanim import type { Mutable } from 'react-native-reanimated/lib/typescript/commonTypes'; import type { DefaultStyle } from 'react-native-reanimated/lib/typescript/hook/commonTypes'; +import { PARTIAL_STYLE } from '@plextv/react-lightning'; import type { LightningElement, LightningElementStyle } from '@plextv/react-lightning'; +import { + type CancelAnimation, + runAnimationProgram, +} from '../animation/runAnimationProgram'; import type { AnimatedObject } from '../types/AnimatedObject'; import type { AnimatedStyle } from '../types/AnimatedStyle'; -import { toLightningAnimationAndStyles } from '../utils/toLightningAnimationAndStyles'; +import { + type ScheduledAnimation, + toLightningAnimationAndStyles, +} from '../utils/toLightningAnimationAndStyles'; +import { useTrackedReaction } from './useTrackedReaction'; type UseAnimatedStyleFn = (...args: Parameters) => AnimatedStyle; -function computeAndSetStyles( - updater: () => AnimatedObject, +type Runners = WeakMap; + +function setStyles( + view: LightningElement, + transition: ReturnType['transition'], + style: ReturnType['style'], + schedules: ScheduledAnimation[], + runners: Runners, +): void { + // Cancel any program still playing on this view before re-applying, so a + // reset (e.g. a shared value set back to a static value) stops the old one. + runners.get(view)?.forEach((cancel) => cancel()); + runners.delete(view); + + // 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; + + view.setProps({ + transition, + // setProps expects lightning props, but we will just pass through the raw + // styles from the useAnimatedStyle and let the transforms take care of + // converting the CSS styles to lightning + style: style as LightningElementStyle, + }); + + if (schedules.length) { + runners.set( + view, + schedules.map((schedule) => + runAnimationProgram(view, schedule.prop, schedule.program), + ), + ); + } +} + +type AppliedStyles = { + transition: ReturnType['transition']; + style: ReturnType['style']; + schedules: ScheduledAnimation[]; +} | null; + +function applyComputedStyle( + computedStyle: AnimatedObject, views: Set, + lastApplied: { current: AppliedStyles }, + runners: Runners, ): void { - const computedStyle = updater(); - const { transition, style } = toLightningAnimationAndStyles(computedStyle); + const { transition, style, schedules } = toLightningAnimationAndStyles(computedStyle); + + lastApplied.current = { transition, style, schedules }; for (const view of views) { - view.setProps({ - transition, - // setProps expects lightning props, but we will just pass through the raw - // styles from the useAnimatedStyle and let the transforms take care of - // converting the CSS styles to lightning - style: style as LightningElementStyle, - }); + setStyles(view, transition, style, schedules, runners); } } @@ -34,23 +82,39 @@ let idCount = 0; export const useAnimatedStyle: UseAnimatedStyleFn = (updater, dependencies) => { const [views] = useState(() => new Set()); + const [runners] = useState(() => new WeakMap()); + // Without explicit deps we infer them by tracking which shared values the + // updater reads (no babel plugin runs on Lightning to infer from closure). + const autoTrack = dependencies === undefined; const inputs: DependencyList = dependencies ?? []; - const timerRef = useRef(0); + const pendingRef = useRef(false); + const lastApplied = useRef(null); - // Debounce this call so we don't end up calculating the styles multiple times - // when updating multiple properties in the same hook + // Coalesce shared-value updates from the same JS turn into one style + // computation. Must be a microtask, not a timer: timers fire after the + // frame paints, so scroll-linked styles would trail the scroll by a frame. const applyStyles = () => { - if (timerRef.current) { - window.clearTimeout(timerRef.current); + if (pendingRef.current) { + return; } - timerRef.current = window.setTimeout(() => { - computeAndSetStyles(updater, views); - timerRef.current = 0; - }, 2); + pendingRef.current = true; + + queueMicrotask(() => { + pendingRef.current = false; + applyComputedStyle(updater(), views, lastApplied, runners); + }); }; + useTrackedReaction(autoTrack ? updater : null, (computedStyle) => { + applyComputedStyle(computedStyle, views, lastApplied, runners); + }); + useEffect(() => { + if (autoTrack) { + return; + } + const id = idCount; idCount += 1; @@ -70,9 +134,24 @@ export const useAnimatedStyle: UseAnimatedStyleFn = (updater, dependencies) => { } applyStyles(); }; - }, [inputs, applyStyles]); + }, [autoTrack, inputs, applyStyles]); return { viewsRef: views, + // A view registering after styles were already pushed (recycled cells, + // re-created nodes) missed that push and a resting shared value may never + // change again. Replay only what was already applied — never compute a + // fresh value here, that would push states the normal flow never emitted. + applyToView: (view: LightningElement) => { + if (lastApplied.current) { + setStyles( + view, + lastApplied.current.transition, + lastApplied.current.style, + lastApplied.current.schedules, + runners, + ); + } + }, }; }; diff --git a/packages/plugin-reanimated/src/exports/useComposedEventHandler.ts b/packages/plugin-reanimated/src/exports/useComposedEventHandler.ts index b760eaa0..ef30eb11 100644 --- a/packages/plugin-reanimated/src/exports/useComposedEventHandler.ts +++ b/packages/plugin-reanimated/src/exports/useComposedEventHandler.ts @@ -1,10 +1,15 @@ // oxlint-disable typescript/no-explicit-any -- Valid use of any here type EventHandler = (...args: any[]) => void; -export function useComposedEventHandler(...handlers: EventHandler[]) { +// Mirrors reanimated's public API: a single array of handlers, not rest args. +export function useComposedEventHandler( + handlers: (EventHandler | null | undefined)[], +) { return (...args: any[]): void => { for (const handler of handlers) { - handler(...args); + if (typeof handler === 'function') { + handler(...args); + } } }; } diff --git a/packages/plugin-reanimated/src/exports/useDerivedValue.ts b/packages/plugin-reanimated/src/exports/useDerivedValue.ts new file mode 100644 index 00000000..4b38c00f --- /dev/null +++ b/packages/plugin-reanimated/src/exports/useDerivedValue.ts @@ -0,0 +1,24 @@ +import type { DependencyList } from 'react'; +import { useState } from 'react'; +import type { DerivedValue } from 'react-native-reanimated-original'; +import { makeMutable } from 'react-native-reanimated-original'; + +import { instrumentSharedValue } from '../utils/sharedValueTracking'; +import { useTrackedReaction } from './useTrackedReaction'; + +export function useDerivedValue( + updater: () => Value, + dependencies?: DependencyList, +): DerivedValue { + const [mutable] = useState(() => instrumentSharedValue(makeMutable(updater()))); + + useTrackedReaction( + updater, + (result) => { + mutable.value = result; + }, + dependencies, + ); + + return mutable as DerivedValue; +} diff --git a/packages/plugin-reanimated/src/exports/useSharedValue.ts b/packages/plugin-reanimated/src/exports/useSharedValue.ts new file mode 100644 index 00000000..50de2173 --- /dev/null +++ b/packages/plugin-reanimated/src/exports/useSharedValue.ts @@ -0,0 +1,14 @@ +import { + makeMutable as makeMutableOriginal, + useSharedValue as useSharedValueOriginal, +} from 'react-native-reanimated-original'; + +import { instrumentSharedValue } from '../utils/sharedValueTracking'; + +// Wrapped so reads are trackable (see sharedValueTracking); behavior of the +// value itself is untouched. +export const useSharedValue: typeof useSharedValueOriginal = (initialValue) => + instrumentSharedValue(useSharedValueOriginal(initialValue)); + +export const makeMutable: typeof makeMutableOriginal = (initialValue) => + instrumentSharedValue(makeMutableOriginal(initialValue)); diff --git a/packages/plugin-reanimated/src/exports/useTrackedReaction.ts b/packages/plugin-reanimated/src/exports/useTrackedReaction.ts new file mode 100644 index 00000000..f302fef8 --- /dev/null +++ b/packages/plugin-reanimated/src/exports/useTrackedReaction.ts @@ -0,0 +1,93 @@ +import type { DependencyList } from 'react'; +import { useEffect, useRef, useState } from 'react'; +import type { Mutable } from 'react-native-reanimated/lib/typescript/commonTypes'; + +import { collectSharedValueReads, nextTrackingListenerId } from '../utils/sharedValueTracking'; + +type TrackedState = { + compute: (() => T) | null; + apply: (result: T) => void; +}; + +/** + * Runs `compute` with shared-value read tracking and re-runs it (microtask + * coalesced) whenever one of the values it read changes. With explicit + * `dependencies` the subscription set still comes from tracking; the deps only + * gate the per-render recompute, mirroring reanimated's web behavior. + */ +export function useTrackedReaction( + compute: (() => T) | null, + apply: (result: T) => void, + dependencies?: DependencyList, +): void { + const [listenerId] = useState(nextTrackingListenerId); + const stateRef = useRef>({ compute, apply }); + const subscribedRef = useRef(new Set>()); + const pendingRef = useRef(false); + const disposedRef = useRef(false); + + const [schedule] = useState(() => (): void => { + if (pendingRef.current) { + return; + } + + pendingRef.current = true; + + queueMicrotask(() => { + pendingRef.current = false; + + const { compute: currentCompute, apply: currentApply } = stateRef.current; + + if (!currentCompute) { + return; + } + + const { result, reads } = collectSharedValueReads(currentCompute); + const subscribed = subscribedRef.current; + + if (!disposedRef.current) { + for (const value of subscribed) { + if (!reads.has(value)) { + value.removeListener(listenerId); + subscribed.delete(value); + } + } + + for (const value of reads) { + // Re-adding an id replaces the callback, keeping listeners current. + value.addListener(listenerId, schedule); + subscribed.add(value); + } + } + + currentApply(result); + }); + }); + + useEffect( + () => { + stateRef.current = { compute, apply }; + + if (compute) { + schedule(); + } + }, + // Per-render on purpose when no deps are given: plain props captured by + // the updater only refresh through re-renders. + dependencies ? [schedule, ...dependencies] : undefined, + ); + + useEffect(() => { + disposedRef.current = false; + + return () => { + disposedRef.current = true; + + for (const value of subscribedRef.current) { + value.removeListener(listenerId); + } + + subscribedRef.current.clear(); + }; + }, [listenerId]); +} diff --git a/packages/plugin-reanimated/src/exports/withDelay.tsx b/packages/plugin-reanimated/src/exports/withDelay.tsx index dfd61375..815d6c3e 100644 --- a/packages/plugin-reanimated/src/exports/withDelay.tsx +++ b/packages/plugin-reanimated/src/exports/withDelay.tsx @@ -1,4 +1,5 @@ import type { AnimatedValue } from '../animation/AnimatedValue'; +import { delayProgram } from '../animation/animationProgram'; export type WithDelayFn = ( delayMs: number, @@ -7,6 +8,12 @@ export type WithDelayFn = ( ) => AnimatedValue; export const withDelay: WithDelayFn = (delayMs, animation) => { + if (animation.program) { + animation.program = delayProgram(animation.program, delayMs); + + return animation; + } + animation.lngAnimation.delay = delayMs; return animation; diff --git a/packages/plugin-reanimated/src/exports/withRepeat.ts b/packages/plugin-reanimated/src/exports/withRepeat.ts index 226d1cf6..e36f4e58 100644 --- a/packages/plugin-reanimated/src/exports/withRepeat.ts +++ b/packages/plugin-reanimated/src/exports/withRepeat.ts @@ -1,4 +1,5 @@ import type { AnimatedValue } from '../animation/AnimatedValue'; +import { repeatProgram } from '../animation/animationProgram'; export type WithRepeatFn = ( animation: AnimatedValue, @@ -11,6 +12,13 @@ export const withRepeat: WithRepeatFn = ( repeatCount = 2, reverse = false, ) => { + if (animation.program) { + animation.program = repeatProgram(animation.program, repeatCount, reverse); + + return animation; + } + + // Single step: let the renderer loop it directly (cheap, GPU-driven). animation.lngAnimation.loop = repeatCount === -1; animation.lngAnimation.repeat = repeatCount; animation.lngAnimation.stopMethod = reverse ? 'reverse' : false; diff --git a/packages/plugin-reanimated/src/exports/withSequence.ts b/packages/plugin-reanimated/src/exports/withSequence.ts index 0712d980..d229bc72 100644 --- a/packages/plugin-reanimated/src/exports/withSequence.ts +++ b/packages/plugin-reanimated/src/exports/withSequence.ts @@ -1,24 +1,31 @@ import type { AnimatableValue, - AnimationObject, + ReduceMotion, withSequence as withSequenceRN, } from 'react-native-reanimated-original'; +import { AnimatedValue } from '../animation/AnimatedValue'; +import { sequenceProgram } from '../animation/animationProgram'; + export function withSequence( - _reduceMotion: string, - ...animations: AnimatableValue[] + reduceMotionOrFirst: ReduceMotion | AnimatableValue, + ...rest: AnimatableValue[] ): ReturnType { - console.error( - '[Reanimated] withSequence is unsupported. Consider building a custom animation in lightning directly instead. Returning just the first animation.', - ); + // reanimated allows an optional ReduceMotion string as the first arg + const animations = + typeof reduceMotionOrFirst === 'string' + ? rest + : [reduceMotionOrFirst, ...rest]; - const returnAnimation = animations[0]; + const values = animations.filter( + (animation) => animation instanceof AnimatedValue, + ) as unknown as AnimatedValue[]; - if (!returnAnimation) { + if (!values.length) { throw new Error('[Reanimated] withSequence requires at least one animation.'); } - return typeof returnAnimation === 'function' - ? (returnAnimation as () => AnimationObject)() - : (returnAnimation as AnimationObject); + return AnimatedValue.fromProgram( + sequenceProgram(values.map((value) => value.toProgram())), + ) as unknown as ReturnType; } diff --git a/packages/plugin-reanimated/src/index.ts b/packages/plugin-reanimated/src/index.ts index 49aa0fd6..d5ab7c7d 100644 --- a/packages/plugin-reanimated/src/index.ts +++ b/packages/plugin-reanimated/src/index.ts @@ -48,8 +48,11 @@ export { SlideOutUp, } from './builders/Slide'; +export { useAnimatedReaction } from './exports/useAnimatedReaction'; export { useAnimatedScrollHandler } from './exports/useAnimatedScrollHandler'; export { useAnimatedStyle } from './exports/useAnimatedStyle'; +export { useDerivedValue } from './exports/useDerivedValue'; +export { makeMutable, useSharedValue } from './exports/useSharedValue'; export { useComposedEventHandler } from './exports/useComposedEventHandler'; export { withDelay } from './exports/withDelay'; export { withRepeat } from './exports/withRepeat'; diff --git a/packages/plugin-reanimated/src/types/AnimatedStyle.ts b/packages/plugin-reanimated/src/types/AnimatedStyle.ts index c22aa8ef..55587444 100644 --- a/packages/plugin-reanimated/src/types/AnimatedStyle.ts +++ b/packages/plugin-reanimated/src/types/AnimatedStyle.ts @@ -2,4 +2,10 @@ import type { LightningElement } from '@plextv/react-lightning'; export type AnimatedStyle = { viewsRef: Set; + /** + * Apply the style's current computed value to a single view. Called when a + * view registers, so late-attached or re-created nodes don't miss styles + * whose shared values are at rest (listeners only push on change). + */ + applyToView: (view: LightningElement) => void; }; diff --git a/packages/plugin-reanimated/src/utils/sharedValueTracking.spec.ts b/packages/plugin-reanimated/src/utils/sharedValueTracking.spec.ts new file mode 100644 index 00000000..e99f80a5 --- /dev/null +++ b/packages/plugin-reanimated/src/utils/sharedValueTracking.spec.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'vitest'; + +import { + collectSharedValueReads, + instrumentSharedValue, + nextTrackingListenerId, +} from './sharedValueTracking'; + +// Mirrors the shape reanimated's makeMutableWeb builds: an object literal with +// `value` as an own configurable accessor plus a listener map. +function makeWebMutable(initial: T) { + let value = initial; + const listeners = new Map void>(); + const mutable = { + get value(): T { + return value; + }, + set value(next: T) { + value = next; + listeners.forEach((listener) => listener(next)); + }, + addListener: (id: number, listener: (next: T) => void) => { + listeners.set(id, listener); + }, + removeListener: (id: number) => { + listeners.delete(id); + }, + listenerCount: () => listeners.size, + }; + + return mutable; +} + +describe('sharedValueTracking', () => { + it('collects reads of instrumented values inside the collector', () => { + const a = instrumentSharedValue(makeWebMutable(1)); + const b = instrumentSharedValue(makeWebMutable(2)); + const untouched = instrumentSharedValue(makeWebMutable(3)); + + const { result, reads } = collectSharedValueReads(() => a.value + b.value); + + expect(result).toBe(3); + expect(reads.size).toBe(2); + expect(reads.has(a as never)).toBe(true); + expect(reads.has(b as never)).toBe(true); + expect(reads.has(untouched as never)).toBe(false); + }); + + it('does not collect reads outside a collector', () => { + const a = instrumentSharedValue(makeWebMutable(1)); + + expect(a.value).toBe(1); + + const { reads } = collectSharedValueReads(() => 0); + + expect(reads.size).toBe(0); + }); + + it('keeps get/set behavior and listeners intact after instrumenting', () => { + const a = instrumentSharedValue(makeWebMutable(1)); + const seen: number[] = []; + + a.addListener(1, (next) => seen.push(next)); + a.value = 5; + + expect(a.value).toBe(5); + expect(seen).toEqual([5]); + }); + + it('instruments a value only once', () => { + const raw = makeWebMutable(1); + + instrumentSharedValue(raw); + instrumentSharedValue(raw); + + const { reads } = collectSharedValueReads(() => raw.value); + + expect(reads.size).toBe(1); + }); + + it('leaves objects without a configurable value accessor alone', () => { + const plain = { value: 1 }; + + Object.defineProperty(plain, 'value', { configurable: false, writable: true }); + + expect(instrumentSharedValue(plain)).toBe(plain); + + const { reads } = collectSharedValueReads(() => plain.value); + + expect(reads.size).toBe(0); + }); + + it('restores the previous collector on nested collections', () => { + const outer = instrumentSharedValue(makeWebMutable(1)); + const inner = instrumentSharedValue(makeWebMutable(2)); + + const { reads } = collectSharedValueReads(() => { + const nested = collectSharedValueReads(() => inner.value); + + expect(nested.reads.has(inner as never)).toBe(true); + expect(nested.reads.has(outer as never)).toBe(false); + + return outer.value; + }); + + expect(reads.has(outer as never)).toBe(true); + expect(reads.has(inner as never)).toBe(false); + }); + + it('hands out unique negative listener ids', () => { + const first = nextTrackingListenerId(); + const second = nextTrackingListenerId(); + + expect(first).toBeLessThan(0); + expect(second).toBeLessThan(first); + }); +}); diff --git a/packages/plugin-reanimated/src/utils/sharedValueTracking.ts b/packages/plugin-reanimated/src/utils/sharedValueTracking.ts new file mode 100644 index 00000000..b088988e --- /dev/null +++ b/packages/plugin-reanimated/src/utils/sharedValueTracking.ts @@ -0,0 +1,65 @@ +import type { Mutable } from 'react-native-reanimated/lib/typescript/commonTypes'; + +// No reanimated babel plugin runs on Lightning, so hooks can't infer their +// dependencies from the updater's closure. Instead we rewrite each shared +// value's `value` getter to report reads to an active collector, and the hooks +// subscribe to exactly what their updater read. + +let activeReads: Set> | null = null; + +const instrumented = new WeakSet(); + +export function instrumentSharedValue(mutable: T): T { + const target = mutable as object; + + if (instrumented.has(target)) { + return mutable; + } + + const descriptor = Object.getOwnPropertyDescriptor(target, 'value'); + + if (!descriptor?.get || !descriptor.set || !descriptor.configurable) { + return mutable; + } + + const originalGet = descriptor.get; + + Object.defineProperty(target, 'value', { + ...descriptor, + get(): unknown { + activeReads?.add(mutable as Mutable); + + return originalGet.call(target); + }, + }); + + instrumented.add(target); + + return mutable; +} + +export function collectSharedValueReads(fn: () => T): { + result: T; + reads: Set>; +} { + const previous = activeReads; + const reads = new Set>(); + + activeReads = reads; + + try { + return { result: fn(), reads }; + } finally { + activeReads = previous; + } +} + +// Negative ids so we never collide with reanimated's web mapper ids (10000+) +// or the positive ids useAnimatedStyle hands to explicit-deps listeners. +let trackingIdCount = 0; + +export function nextTrackingListenerId(): number { + trackingIdCount -= 1; + + return trackingIdCount; +} diff --git a/packages/plugin-reanimated/src/utils/toLightningAnimationAndStyles.test.ts b/packages/plugin-reanimated/src/utils/toLightningAnimationAndStyles.test.ts new file mode 100644 index 00000000..9926d834 --- /dev/null +++ b/packages/plugin-reanimated/src/utils/toLightningAnimationAndStyles.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from 'vitest'; +import { AnimatedValue } from '../animation/AnimatedValue'; +import { leafProgram, sequenceProgram } from '../animation/animationProgram'; +import { AnimationType } from '../types/AnimationType'; +import { toLightningAnimationAndStyles } from './toLightningAnimationAndStyles'; + +// The real module is a vite-plugin alias with no node resolution; only its +// ReduceMotion enum is read at runtime (by spring), the rest is type-only. +vi.mock('react-native-reanimated-original', () => ({ + ReduceMotion: { System: 'system', Always: 'always', Never: 'never' }, +})); + +const timing = (toValue: number) => + new AnimatedValue(AnimationType.Timing, toValue, { duration: 200 }); + +const settings = () => ({ + duration: 200, + easing: 'linear' as const, + delay: 0, + loop: false, + repeat: 0, + stopMethod: false as const, +}); + +describe('toLightningAnimationAndStyles', () => { + // Switch's thumb: a plain withTiming rides the node's one-shot transition. + it('maps a plain animated translateX to the x transition, not a schedule', () => { + const { transition, schedules } = toLightningAnimationAndStyles({ + transform: [{ translateX: timing(20) }], + } as never); + + expect(transition.x).toBeDefined(); + expect(schedules).toHaveLength(0); + }); + + it('maps animated opacity to the alpha transition', () => { + const { transition, style, schedules } = toLightningAnimationAndStyles({ + opacity: timing(0.3), + } as never); + + expect((style as { alpha?: number }).alpha).toBe(0.3); + expect(transition.alpha).toBeDefined(); + expect(schedules).toHaveLength(0); + }); + + // ScrollingText's marquee: a composed program plays step-by-step on x. + it('maps a composed translateX program to an x schedule', () => { + const program = sequenceProgram([ + leafProgram({ toValue: -840, lngAnimation: settings() }), + leafProgram({ toValue: 0, lngAnimation: settings() }), + ]); + + const { transition, schedules } = toLightningAnimationAndStyles({ + transform: [{ translateX: AnimatedValue.fromProgram(program) }], + } as never); + + expect(schedules).toHaveLength(1); + expect(schedules[0]?.prop).toBe('x'); + // A program is driven step-by-step, so it must not also take the one-shot x transition. + expect(transition.x).toBeUndefined(); + }); + + it('passes plain (non-animated) values straight through', () => { + const { style, transition, schedules } = toLightningAnimationAndStyles({ + opacity: 1, + } as never); + + expect(style.opacity).toBe(1); + expect(transition.alpha).toBeUndefined(); + expect(schedules).toHaveLength(0); + }); +}); diff --git a/packages/plugin-reanimated/src/utils/toLightningAnimationAndStyles.ts b/packages/plugin-reanimated/src/utils/toLightningAnimationAndStyles.ts index 1cc998de..534e6c52 100644 --- a/packages/plugin-reanimated/src/utils/toLightningAnimationAndStyles.ts +++ b/packages/plugin-reanimated/src/utils/toLightningAnimationAndStyles.ts @@ -5,6 +5,7 @@ import { convertCSSTransformToLightning } from '@plextv/react-lightning-plugin-c import type { Transform } from '@plextv/react-lightning-plugin-flexbox'; import { AnimatedValue } from '../animation/AnimatedValue'; +import { type AnimationProgram, mapProgram } from '../animation/animationProgram'; import type { AnimatedObject } from '../types/AnimatedObject'; import { getTransitionProperty } from '../utils/getTransitionProperty'; @@ -16,23 +17,30 @@ type DefaultStyleWithLightningTransform = Omit & { transform?: Transform; }; +export type ScheduledAnimation = { + prop: keyof LightningElementStyle; + program: AnimationProgram; +}; + function applyTransforms( style: DefaultStyleWithLightningTransform, transition: LightningTransition, + schedules: ScheduledAnimation[], animatableTransforms: AnimatableTransform | AnimatableTransform[], ) { if (Array.isArray(animatableTransforms)) { for (const animatableTransform of animatableTransforms) { - applyTransform(style, transition, animatableTransform); + applyTransform(style, transition, schedules, animatableTransform); } } else { - applyTransform(style, transition, animatableTransforms); + applyTransform(style, transition, schedules, animatableTransforms); } } function applyTransform( style: DefaultStyleWithLightningTransform, transition: LightningTransition, + schedules: ScheduledAnimation[], animatableTransform: AnimatableTransform, ) { for (const [key, value] of Object.entries(animatableTransform)) { @@ -42,6 +50,31 @@ function applyTransform( case 'translate': case 'translateX': case 'translateY': + // A composed program drives the axis step-by-step instead of a + // one-shot transition; map each step's px target onto x / y. + if (value instanceof AnimatedValue && value.program) { + const program = value.program; + const toAxis = (axis: 'x' | 'y') => + mapProgram(program, (v) => { + const converted = convertCSSTransformToLightning(key, v) as Record< + string, + number | string + >; + + return converted[axis] ?? v; + }); + + if (key === 'translate' || key === 'translateX') { + schedules.push({ prop: 'x', program: toAxis('x') }); + } + + if (key === 'translate' || key === 'translateY') { + schedules.push({ prop: 'y', program: toAxis('y') }); + } + + break; + } + // Using our lightning style transform instead of RN style.transform = { ...style.transform, @@ -63,18 +96,18 @@ function applyTransform( case 'scaleX': case 'scaleY': if (key === 'scale' || key === 'scaleX') { - applyStyle(style, transition, 'scaleX', value as AnimatedValue); + applyStyle(style, transition, schedules, 'scaleX', value as AnimatedValue); } if (key === 'scale' || key === 'scaleY') { - applyStyle(style, transition, 'scaleY', value as AnimatedValue); + applyStyle(style, transition, schedules, 'scaleY', value as AnimatedValue); } break; case 'rotate': - applyStyle(style, transition, 'rotation', value as AnimatedValue); + applyStyle(style, transition, schedules, 'rotation', value as AnimatedValue); break; default: - applyStyle(style, transition, key as keyof DefaultStyle, value); + applyStyle(style, transition, schedules, key as keyof DefaultStyle, value); break; } } @@ -83,12 +116,20 @@ function applyTransform( function applyStyle( style: DefaultStyleWithLightningTransform, transition: LightningTransition, + schedules: ScheduledAnimation[], prop: K, value: AnimatedObject[K] | (AnimatedObject[K] & string), ) { if (value instanceof AnimatedValue) { const transitionProp = getTransitionProperty(prop as keyof DefaultStyle); + // A program plays step-by-step; a plain value takes the one-shot transition. + if (value.program) { + schedules.push({ prop: transitionProp, program: value.program }); + + return; + } + // oxlint-disable-next-line typescript/no-explicit-any -- Just passing through (style as any)[transitionProp] = value.value as T[K]; transition[transitionProp] = value.lngAnimation; @@ -101,9 +142,11 @@ function applyStyle( export function toLightningAnimationAndStyles(computedStyle: AnimatedObject): { transition: LightningTransition; style: DefaultStyleWithLightningTransform; + schedules: ScheduledAnimation[]; } { const style: DefaultStyleWithLightningTransform = {}; const transition: LightningTransition = {}; + const schedules: ScheduledAnimation[] = []; for (const key in computedStyle) { const prop = key as keyof AnimatedObject; @@ -116,12 +159,13 @@ export function toLightningAnimationAndStyles(computedStyle: AnimatedObject Array.from({ length: n }, (_, i) => ({ id: i })); +const makeData = (n: number) => + Array.from({ length: n }, (_, i) => ({ id: i })); describe('LayoutManager', () => { describe('single column', () => { - it('positions items sequentially using estimatedItemSize', () => { + it('positions unmeasured items sequentially using the default size', () => { const lm = new LayoutManager({ data: makeData(3), - estimatedItemSize: 100, numColumns: 1, cellCrossSize: 200, }); expect(lm.getLayout(0)).toEqual( - expect.objectContaining({ offset: 0, size: 100, crossSize: 200 }), + expect.objectContaining({ + offset: 0, + size: DEFAULT_ITEM_SIZE, + crossSize: 200, + }), ); - expect(lm.getLayout(1)).toEqual(expect.objectContaining({ offset: 100, size: 100 })); - expect(lm.getLayout(2)).toEqual(expect.objectContaining({ offset: 200, size: 100 })); - expect(lm.totalSize).toBe(300); + expect(lm.getLayout(1)).toEqual( + expect.objectContaining({ + offset: DEFAULT_ITEM_SIZE, + size: DEFAULT_ITEM_SIZE, + }), + ); + expect(lm.getLayout(2)).toEqual( + expect.objectContaining({ + offset: DEFAULT_ITEM_SIZE * 2, + size: DEFAULT_ITEM_SIZE, + }), + ); + expect(lm.totalSize).toBe(DEFAULT_ITEM_SIZE * 3); }); it('uses overrideItemLayout for custom sizes', () => { const sizes = [50, 100, 75]; const lm = new LayoutManager({ data: makeData(3), - estimatedItemSize: 100, numColumns: 1, cellCrossSize: 200, overrideItemLayout: (layout, _item, index) => { @@ -44,7 +57,6 @@ describe('LayoutManager', () => { it('returns undefined for out-of-range index', () => { const lm = new LayoutManager({ data: makeData(2), - estimatedItemSize: 100, numColumns: 1, cellCrossSize: 200, }); @@ -56,35 +68,36 @@ describe('LayoutManager', () => { const data: Array<{ id: number } | null> = [{ id: 0 }, null, { id: 2 }]; const lm = new LayoutManager({ data, - estimatedItemSize: 100, numColumns: 1, cellCrossSize: 200, }); - expect(lm.getLayout(0)?.size).toBe(100); + expect(lm.getLayout(0)?.size).toBe(DEFAULT_ITEM_SIZE); expect(lm.getLayout(1)?.size).toBe(0); - expect(lm.getLayout(1)?.offset).toBe(100); - expect(lm.getLayout(2)?.offset).toBe(100); - expect(lm.totalSize).toBe(200); + expect(lm.getLayout(1)?.offset).toBe(DEFAULT_ITEM_SIZE); + expect(lm.getLayout(2)?.offset).toBe(DEFAULT_ITEM_SIZE); + expect(lm.totalSize).toBe(DEFAULT_ITEM_SIZE * 2); }); it('collapses undefined data entries to size 0', () => { - const data: Array<{ id: number } | undefined> = [{ id: 0 }, undefined, { id: 2 }]; + const data: Array<{ id: number } | undefined> = [ + { id: 0 }, + undefined, + { id: 2 }, + ]; const lm = new LayoutManager({ data, - estimatedItemSize: 100, numColumns: 1, cellCrossSize: 200, }); expect(lm.getLayout(1)?.size).toBe(0); - expect(lm.getLayout(2)?.offset).toBe(100); + expect(lm.getLayout(2)?.offset).toBe(DEFAULT_ITEM_SIZE); }); it('honours override.size = 0 to collapse a row', () => { const lm = new LayoutManager({ data: makeData(3), - estimatedItemSize: 100, numColumns: 1, cellCrossSize: 200, overrideItemLayout: (layout, _item, index) => { @@ -95,8 +108,8 @@ describe('LayoutManager', () => { }); expect(lm.getLayout(1)?.size).toBe(0); - expect(lm.getLayout(2)?.offset).toBe(100); - expect(lm.totalSize).toBe(200); + expect(lm.getLayout(2)?.offset).toBe(DEFAULT_ITEM_SIZE); + expect(lm.totalSize).toBe(DEFAULT_ITEM_SIZE * 2); }); }); @@ -104,7 +117,6 @@ describe('LayoutManager', () => { it('positions items in a grid using cellCrossSize', () => { const lm = new LayoutManager({ data: makeData(5), - estimatedItemSize: 100, numColumns: 2, cellCrossSize: 100, }); @@ -125,16 +137,21 @@ describe('LayoutManager', () => { crossSize: 100, }), ); - expect(lm.getLayout(2)).toEqual(expect.objectContaining({ offset: 100, column: 0 })); - expect(lm.getLayout(3)).toEqual(expect.objectContaining({ offset: 100, column: 1 })); - expect(lm.getLayout(4)).toEqual(expect.objectContaining({ offset: 200, column: 0 })); - expect(lm.totalSize).toBe(300); + expect(lm.getLayout(2)).toEqual( + expect.objectContaining({ offset: DEFAULT_ITEM_SIZE, column: 0 }), + ); + expect(lm.getLayout(3)).toEqual( + expect.objectContaining({ offset: DEFAULT_ITEM_SIZE, column: 1 }), + ); + expect(lm.getLayout(4)).toEqual( + expect.objectContaining({ offset: DEFAULT_ITEM_SIZE * 2, column: 0 }), + ); + expect(lm.totalSize).toBe(DEFAULT_ITEM_SIZE * 3); }); it('handles span override (crossSize scales with span)', () => { const lm = new LayoutManager({ data: makeData(3), - estimatedItemSize: 100, numColumns: 3, cellCrossSize: 100, overrideItemLayout: (layout, _item, index) => { @@ -153,7 +170,6 @@ describe('LayoutManager', () => { it('clamps span to available columns', () => { const lm = new LayoutManager({ data: makeData(2), - estimatedItemSize: 100, numColumns: 2, cellCrossSize: 100, overrideItemLayout: (layout) => { @@ -169,22 +185,32 @@ describe('LayoutManager', () => { it('adds separator gap between items in single column', () => { const lm = new LayoutManager({ data: makeData(3), - estimatedItemSize: 100, numColumns: 1, cellCrossSize: 200, separatorSize: 10, }); - expect(lm.getLayout(0)).toEqual(expect.objectContaining({ offset: 0, size: 100 })); - expect(lm.getLayout(1)).toEqual(expect.objectContaining({ offset: 110, size: 100 })); - expect(lm.getLayout(2)).toEqual(expect.objectContaining({ offset: 220, size: 100 })); - expect(lm.totalSize).toBe(320); + expect(lm.getLayout(0)).toEqual( + expect.objectContaining({ offset: 0, size: DEFAULT_ITEM_SIZE }), + ); + expect(lm.getLayout(1)).toEqual( + expect.objectContaining({ + offset: DEFAULT_ITEM_SIZE + 10, + size: DEFAULT_ITEM_SIZE, + }), + ); + expect(lm.getLayout(2)).toEqual( + expect.objectContaining({ + offset: DEFAULT_ITEM_SIZE * 2 + 20, + size: DEFAULT_ITEM_SIZE, + }), + ); + expect(lm.totalSize).toBe(DEFAULT_ITEM_SIZE * 3 + 20); }); it('does not add separator gap between rows in multi column', () => { const lm = new LayoutManager({ data: makeData(5), - estimatedItemSize: 100, numColumns: 2, cellCrossSize: 100, separatorSize: 20, @@ -192,26 +218,25 @@ describe('LayoutManager', () => { expect(lm.getLayout(0)?.offset).toBe(0); expect(lm.getLayout(1)?.offset).toBe(0); - expect(lm.getLayout(2)?.offset).toBe(100); - expect(lm.getLayout(3)?.offset).toBe(100); - expect(lm.getLayout(4)?.offset).toBe(200); - expect(lm.totalSize).toBe(300); + expect(lm.getLayout(2)?.offset).toBe(DEFAULT_ITEM_SIZE); + expect(lm.getLayout(3)?.offset).toBe(DEFAULT_ITEM_SIZE); + expect(lm.getLayout(4)?.offset).toBe(DEFAULT_ITEM_SIZE * 2); + expect(lm.totalSize).toBe(DEFAULT_ITEM_SIZE * 3); }); it('does not add separator gap after a zero-size empty row', () => { const data: Array<{ id: number } | null> = [{ id: 0 }, null, { id: 2 }]; const lm = new LayoutManager({ data, - estimatedItemSize: 100, numColumns: 1, cellCrossSize: 200, separatorSize: 10, }); expect(lm.getLayout(0)?.offset).toBe(0); - expect(lm.getLayout(1)?.offset).toBe(110); + expect(lm.getLayout(1)?.offset).toBe(DEFAULT_ITEM_SIZE + 10); expect(lm.getLayout(1)?.size).toBe(0); - expect(lm.getLayout(2)?.offset).toBe(110); + expect(lm.getLayout(2)?.offset).toBe(DEFAULT_ITEM_SIZE + 10); }); }); @@ -219,9 +244,11 @@ describe('LayoutManager', () => { it('returns correct range for a window in the middle', () => { const lm = new LayoutManager({ data: makeData(20), - estimatedItemSize: 100, numColumns: 1, cellCrossSize: 200, + overrideItemLayout: (layout) => { + layout.size = 100; + }, }); const range = lm.getVisibleRange(500, 300, 100); @@ -232,7 +259,6 @@ describe('LayoutManager', () => { it('returns empty range for empty data', () => { const lm = new LayoutManager({ data: [], - estimatedItemSize: 100, numColumns: 1, cellCrossSize: 200, }); @@ -245,7 +271,6 @@ describe('LayoutManager', () => { it('clamps to data bounds', () => { const lm = new LayoutManager({ data: makeData(5), - estimatedItemSize: 100, numColumns: 1, cellCrossSize: 200, }); @@ -258,9 +283,11 @@ describe('LayoutManager', () => { it('handles scroll at the very end', () => { const lm = new LayoutManager({ data: makeData(10), - estimatedItemSize: 100, numColumns: 1, cellCrossSize: 200, + overrideItemLayout: (layout) => { + layout.size = 100; + }, }); const range = lm.getVisibleRange(800, 200, 0); @@ -273,9 +300,11 @@ describe('LayoutManager', () => { it('locates the index at a given main-axis offset', () => { const lm = new LayoutManager({ data: makeData(5), - estimatedItemSize: 100, numColumns: 1, cellCrossSize: 200, + overrideItemLayout: (layout) => { + layout.size = 100; + }, }); expect(lm.findIndexAtOffset(0)).toBe(0); @@ -287,7 +316,6 @@ describe('LayoutManager', () => { it('returns -1 for empty data', () => { const lm = new LayoutManager({ data: [], - estimatedItemSize: 100, numColumns: 1, cellCrossSize: 200, }); @@ -300,13 +328,12 @@ describe('LayoutManager', () => { it('uses measured size in subsequent layouts', () => { const lm = new LayoutManager({ data: makeData(3), - estimatedItemSize: 100, numColumns: 1, cellCrossSize: 200, keyExtractor: (item) => String(item.id), }); - expect(lm.getLayout(1)?.offset).toBe(100); + expect(lm.getLayout(1)?.offset).toBe(DEFAULT_ITEM_SIZE); const changed = lm.reportItemSize('0', 150); expect(changed).toBe(true); @@ -322,7 +349,6 @@ describe('LayoutManager', () => { it('measurement wins over override', () => { const lm = new LayoutManager({ data: makeData(3), - estimatedItemSize: 100, numColumns: 1, cellCrossSize: 200, keyExtractor: (item) => String(item.id), @@ -341,7 +367,6 @@ describe('LayoutManager', () => { it('returns false for zero or negative sizes', () => { const lm = new LayoutManager({ data: makeData(2), - estimatedItemSize: 100, numColumns: 1, cellCrossSize: 200, keyExtractor: (item) => String(item.id), @@ -349,13 +374,45 @@ describe('LayoutManager', () => { expect(lm.reportItemSize('0', 0)).toBe(false); expect(lm.reportItemSize('0', -5)).toBe(false); - expect(lm.getLayout(0)?.size).toBe(100); + expect(lm.getLayout(0)?.size).toBe(DEFAULT_ITEM_SIZE); + }); + + it('isMeasured flips once a real size commits (keyed by userKey)', () => { + const lm = new LayoutManager({ + data: makeData(2), + numColumns: 1, + cellCrossSize: 200, + keyExtractor: (item) => String(item.id), + }); + + expect(lm.isMeasured(0)).toBe(false); + lm.reportItemSize('0', 150); + expect(lm.isMeasured(0)).toBe(true); + expect(lm.isMeasured(1)).toBe(false); + }); + + it('hasOverrideSize reflects whether the caller pins the main-axis size', () => { + const pinned = new LayoutManager({ + data: makeData(2), + numColumns: 1, + cellCrossSize: 200, + overrideItemLayout: (layout) => { + layout.size = 80; + }, + }); + const unpinned = new LayoutManager({ + data: makeData(2), + numColumns: 1, + cellCrossSize: 200, + }); + + expect(pinned.hasOverrideSize(0)).toBe(true); + expect(unpinned.hasOverrideSize(0)).toBe(false); }); it('returns false on no-op reports and defers different values via dampening', () => { const lm = new LayoutManager({ data: makeData(2), - estimatedItemSize: 100, numColumns: 1, cellCrossSize: 200, keyExtractor: (item) => String(item.id), @@ -374,11 +431,27 @@ describe('LayoutManager', () => { expect(lm.getLayout(0)?.size).toBe(150); }); + it('commits a final report immediately, bypassing dampening', () => { + const lm = new LayoutManager({ + data: makeData(2), + numColumns: 1, + cellCrossSize: 200, + keyExtractor: (item) => String(item.id), + }); + + expect(lm.reportItemSize('0', 150)).toBe(true); + // A differing report normally sits in dampening (returns false, stays 150). + expect(lm.reportItemSize('0', 152)).toBe(false); + expect(lm.getLayout(0)?.size).toBe(150); + // A final report (Yoga settled) commits synchronously, no window. + expect(lm.reportItemSize('0', 152, true)).toBe(true); + expect(lm.getLayout(0)?.size).toBe(152); + }); + it('measurements survive index shifts (keyed by userKey)', () => { const data = makeData(3); const lm = new LayoutManager({ data, - estimatedItemSize: 100, numColumns: 1, cellCrossSize: 200, keyExtractor: (item) => String(item.id), @@ -393,7 +466,7 @@ describe('LayoutManager', () => { // The measurement for id=1 follows the userKey across the index // shift. Unmeasured items (id=99 and id=0) fall back to the - // first-measured implicit estimate (150), not `estimatedItemSize`. + // first-measured implicit estimate (150), not the default size. expect(lm.getLayout(0)?.size).toBe(150); expect(lm.getLayout(1)?.size).toBe(150); expect(lm.getLayout(2)?.size).toBe(150); @@ -406,7 +479,6 @@ describe('LayoutManager', () => { // stored but never found, leaving cells stuck at the estimate. const lm = new LayoutManager({ data: makeData(2), - estimatedItemSize: 100, numColumns: 1, cellCrossSize: 200, }); @@ -419,7 +491,6 @@ describe('LayoutManager', () => { const data = makeData(3); const lm = new LayoutManager({ data, - estimatedItemSize: 100, numColumns: 1, cellCrossSize: 200, }); @@ -445,7 +516,6 @@ describe('LayoutManager', () => { const data: Array<{ id: number } | null> = [{ id: 0 }, null]; const lm = new LayoutManager({ data, - estimatedItemSize: 100, numColumns: 1, cellCrossSize: 200, keyExtractor: (item) => String(item?.id), @@ -458,14 +528,13 @@ describe('LayoutManager', () => { it('first measurement becomes the implicit estimate for later unmeasured items', () => { const lm = new LayoutManager({ data: makeData(4), - estimatedItemSize: 100, numColumns: 1, cellCrossSize: 200, keyExtractor: (item) => String(item.id), }); - // Before any measurement: items use the caller's estimatedItemSize. - expect(lm.getLayout(2)?.size).toBe(100); + // Before any measurement: items use the default size. + expect(lm.getLayout(2)?.size).toBe(DEFAULT_ITEM_SIZE); // First measurement comes in. Items 1,2,3 are still unmeasured but // should now use 150 (the first-measured size) as the fallback. @@ -478,7 +547,6 @@ describe('LayoutManager', () => { it('later measurements do NOT update the implicit estimate', () => { const lm = new LayoutManager({ data: makeData(5), - estimatedItemSize: 100, numColumns: 1, cellCrossSize: 200, keyExtractor: (item) => String(item.id), @@ -499,24 +567,22 @@ describe('LayoutManager', () => { it('reportItemEmpty collapses the row to size 0', () => { const lm = new LayoutManager({ data: makeData(3), - estimatedItemSize: 100, numColumns: 1, cellCrossSize: 200, keyExtractor: (item) => String(item.id), }); - expect(lm.getLayout(1)?.size).toBe(100); + expect(lm.getLayout(1)?.size).toBe(DEFAULT_ITEM_SIZE); expect(lm.reportItemEmpty('1')).toBe(true); expect(lm.getLayout(1)?.size).toBe(0); - expect(lm.getLayout(2)?.offset).toBe(100); - expect(lm.totalSize).toBe(200); + expect(lm.getLayout(2)?.offset).toBe(DEFAULT_ITEM_SIZE); + expect(lm.totalSize).toBe(DEFAULT_ITEM_SIZE * 2); }); it('reportItemEmpty is idempotent', () => { const lm = new LayoutManager({ data: makeData(2), - estimatedItemSize: 100, numColumns: 1, cellCrossSize: 200, keyExtractor: (item) => String(item.id), @@ -529,7 +595,6 @@ describe('LayoutManager', () => { it('per-item override.size wins over the implicit estimate', () => { const lm = new LayoutManager({ data: makeData(3), - estimatedItemSize: 100, numColumns: 1, cellCrossSize: 200, keyExtractor: (item) => String(item.id), @@ -552,20 +617,18 @@ describe('LayoutManager', () => { it('recomputes layouts after data change', () => { const lm = new LayoutManager({ data: makeData(3), - estimatedItemSize: 100, numColumns: 1, cellCrossSize: 200, }); - expect(lm.totalSize).toBe(300); + expect(lm.totalSize).toBe(DEFAULT_ITEM_SIZE * 3); expect(lm.updateConfig({ data: makeData(5) })).toBe(true); - expect(lm.totalSize).toBe(500); + expect(lm.totalSize).toBe(DEFAULT_ITEM_SIZE * 5); }); it('recomputes when cellCrossSize changes', () => { const lm = new LayoutManager({ data: makeData(3), - estimatedItemSize: 100, numColumns: 1, cellCrossSize: 200, }); @@ -577,12 +640,11 @@ describe('LayoutManager', () => { it('returns false when nothing changed', () => { const lm = new LayoutManager({ data: makeData(3), - estimatedItemSize: 100, numColumns: 1, cellCrossSize: 200, }); - expect(lm.updateConfig({ estimatedItemSize: 100, numColumns: 1 })).toBe(false); + expect(lm.updateConfig({ numColumns: 1 })).toBe(false); }); }); }); diff --git a/packages/react-lightning-components/src/components/VirtualList/LayoutManager.ts b/packages/react-lightning-components/src/components/VirtualList/LayoutManager.ts index 767c6b63..ec00b67f 100644 --- a/packages/react-lightning-components/src/components/VirtualList/LayoutManager.ts +++ b/packages/react-lightning-components/src/components/VirtualList/LayoutManager.ts @@ -13,9 +13,12 @@ export interface ComputedLayout { crossSize: number; } +// Bootstrap main-axis size for unmeasured items before anything has measured. +// Real sizes come from cell measurements or `overrideItemLayout`. +export const DEFAULT_ITEM_SIZE = 200; + export interface LayoutManagerConfig { - data: ReadonlyArray; - estimatedItemSize: number; + data: readonly T[]; numColumns: number; overrideItemLayout?: OverrideItemLayoutFn; extraData?: unknown; @@ -28,49 +31,54 @@ export interface LayoutManagerConfig { /** * Computes per-item offsets in O(n). Main-axis size is the per-userKey - * measurement, then `overrideItemLayout`, then `estimatedItemSize`. Cross + * measurement, then `overrideItemLayout`, then the first measured size + * (`DEFAULT_ITEM_SIZE` until anything measures). Cross * is always `cellCrossSize` (× span); never measured or aggregated — * that's the rule that keeps the layout loop-free. */ export class LayoutManager { - private static _overrideScratch: { size?: number; span?: number } = {}; + private static _overrideScratch: { + size?: number; + span?: number; + } = {}; private _layouts: ComputedLayout[] = []; private _layoutCount = 0; private _totalSize = 0; private _dirty = true; - private _data: ReadonlyArray; - private _estimatedItemSize: number; + private _data: readonly T[]; private _numColumns: number; private _overrideItemLayout?: OverrideItemLayoutFn; private _extraData?: unknown; private _separatorSize: number; private _cellCrossSize: number; private _keyExtractor?: (item: T, index: number) => string; - private _measuredSizes: Map = new Map(); + private _measuredSizes = new Map(); /** While true, reports accumulate per-userKey and skip dampening. Drained on `setBatching(false)`. */ private _batching = false; - private _batchedSizes: Map = new Map(); + private _batchedSizes = new Map(); /** * Per-userKey stability window. A different incoming value sits pending * until either matched after `_STABILITY_MS` or the backstop timer * fires. Filters multi-frame measurement cascades during scroll/focus * animations and async content settling. */ - private _pendingSizes: Map = new Map(); + private _pendingSizes = new Map< + string, + { size: number; firstSeenAt: number } + >(); /** Backstop timers — required because a cell can push once and go quiet (props stable). */ - private _pendingTimers: Map> = new Map(); + private _pendingTimers = new Map>(); private _onChange?: () => void; private static readonly _STABILITY_MS = 120; /** - * Implicit fallback for unmeasured items once any cell has measured — - * usually a much better predictor than the caller's estimate. Locked on - * first measurement so subsequent cells don't cascade-shift the fallback. + * Implicit fallback for unmeasured items once any cell has measured. + * Locked on first measurement so subsequent cells don't cascade-shift + * the fallback. */ private _firstMeasuredSize = 0; constructor(config: LayoutManagerConfig) { this._data = config.data; - this._estimatedItemSize = config.estimatedItemSize; this._numColumns = Math.max(1, config.numColumns); this._overrideItemLayout = config.overrideItemLayout; this._extraData = config.extraData; @@ -170,14 +178,6 @@ export class LayoutManager { changed = true; } - if ( - config.estimatedItemSize !== undefined && - config.estimatedItemSize !== this._estimatedItemSize - ) { - this._estimatedItemSize = config.estimatedItemSize; - changed = true; - } - if (config.numColumns !== undefined) { const nc = Math.max(1, config.numColumns); @@ -195,22 +195,34 @@ export class LayoutManager { changed = true; } - if (config.extraData !== undefined && config.extraData !== this._extraData) { + if ( + config.extraData !== undefined && + config.extraData !== this._extraData + ) { this._extraData = config.extraData; changed = true; } - if (config.separatorSize !== undefined && config.separatorSize !== this._separatorSize) { + if ( + config.separatorSize !== undefined && + config.separatorSize !== this._separatorSize + ) { this._separatorSize = config.separatorSize; changed = true; } - if (config.cellCrossSize !== undefined && config.cellCrossSize !== this._cellCrossSize) { + if ( + config.cellCrossSize !== undefined && + config.cellCrossSize !== this._cellCrossSize + ) { this._cellCrossSize = config.cellCrossSize; changed = true; } - if (config.keyExtractor !== undefined && config.keyExtractor !== this._keyExtractor) { + if ( + config.keyExtractor !== undefined && + config.keyExtractor !== this._keyExtractor + ) { this._keyExtractor = config.keyExtractor; changed = true; } @@ -226,9 +238,11 @@ export class LayoutManager { * Records the rendered main-axis size keyed by `userKey`. Returns `true` * when the size committed synchronously (caller should bump * layoutVersion). Rejects size ≤ 0 / non-finite — use `reportItemEmpty` - * for genuinely-empty rows. + * for genuinely-empty rows. `final` (Yoga reported layout settled) commits + * the size immediately, bypassing the stability window — the size can't + * grow further, so there's nothing to dampen. */ - reportItemSize(userKey: string, size: number): boolean { + reportItemSize(userKey: string, size: number, final = false): boolean { if (!Number.isFinite(size) || size <= 0) { return false; } @@ -247,8 +261,9 @@ export class LayoutManager { return false; } - // First measurement — apply immediately, nothing to thrash against. - if (existing == null) { + // First measurement, or a fixpoint-settled size — apply immediately, + // nothing to thrash against. + if (existing == null || final) { this._measuredSizes.set(userKey, size); if (this._firstMeasuredSize === 0) { @@ -374,6 +389,28 @@ export class LayoutManager { return this._layouts[index]; } + private _userKeyFor(index: number): string | undefined { + const item = this._data[index]; + + if (item == null) { + return undefined; + } + + return this._keyExtractor ? this._keyExtractor(item, index) : String(index); + } + + /** True once the cell has reported a real (committed) main-axis size. */ + isMeasured(index: number): boolean { + const userKey = this._userKeyFor(index); + + return userKey != null && this._measuredSizes.has(userKey); + } + + /** True when the caller pins the main-axis size via `overrideItemLayout` (no measurement needed). */ + hasOverrideSize(index: number): boolean { + return this._getOverride(index).size != null; + } + /** * Returns the layout index whose [offset, offset+size) range contains the * given offset (in item-space). Used to map a focused descendant's @@ -484,7 +521,11 @@ export class LayoutManager { } } - private _resolveSize(index: number, isEmpty: boolean, override: { size?: number }): number { + private _resolveSize( + index: number, + isEmpty: boolean, + override: { size?: number }, + ): number { if (isEmpty) { return 0; } @@ -495,7 +536,9 @@ export class LayoutManager { // Match VirtualListCell: it reports with String(index) when no // keyExtractor is configured, so per-item lookup must use the same // key. Without this, measurements would be stored but never found. - const userKey = this._keyExtractor ? this._keyExtractor(item, index) : String(index); + const userKey = this._keyExtractor + ? this._keyExtractor(item, index) + : String(index); const measured = this._measuredSizes.get(userKey); if (measured != null) { @@ -507,11 +550,12 @@ export class LayoutManager { return override.size; } - // Prefer the first-measured size over the caller's estimate once any - // cell has reported. Per-key measurements above still win for cells - // that have actually been seen — this is the fallback for unmeasured - // ones only. - return this._firstMeasuredSize > 0 ? this._firstMeasuredSize : this._estimatedItemSize; + // Prefer the first-measured size once any cell has reported. Per-key + // measurements above still win for cells that have actually been seen — + // this is the fallback for unmeasured ones only. + return this._firstMeasuredSize > 0 + ? this._firstMeasuredSize + : DEFAULT_ITEM_SIZE; } private _recomputeSingleColumn(count: number): void { @@ -567,7 +611,10 @@ export class LayoutManager { const item = this._data[i]; const isEmpty = item === undefined || item === null; const override = this._getOverride(i); - const span = Math.min(override.span ?? 1, this._numColumns - columnsUsed); + const span = Math.min( + override.span ?? 1, + this._numColumns - columnsUsed, + ); const size = this._resolveSize(i, isEmpty, override); layout.offset = offset; @@ -589,7 +636,10 @@ export class LayoutManager { this._totalSize = offset; } - private _getOverride(index: number): { size?: number; span?: number } { + private _getOverride(index: number): { + size?: number; + span?: number; + } { LayoutManager._overrideScratch.size = undefined; LayoutManager._overrideScratch.span = undefined; diff --git a/packages/react-lightning-components/src/components/VirtualList/RevealGate.spec.ts b/packages/react-lightning-components/src/components/VirtualList/RevealGate.spec.ts new file mode 100644 index 00000000..f5af777d --- /dev/null +++ b/packages/react-lightning-components/src/components/VirtualList/RevealGate.spec.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest'; + +import { RevealGate } from './RevealGate'; + +const QUIET = 120; +const MAX = 1000; + +describe('RevealGate', () => { + it('reports Infinity until a key is noted', () => { + const gate = new RevealGate(); + + expect(gate.timeUntilSettled('a', 0, QUIET, MAX)).toBe(Infinity); + }); + + it('counts down the quiet window from the last size change', () => { + const gate = new RevealGate(); + + gate.note('a', 456, 0); + + expect(gate.timeUntilSettled('a', 0, QUIET, MAX)).toBe(QUIET); + expect(gate.timeUntilSettled('a', 100, QUIET, MAX)).toBe(20); + expect(gate.timeUntilSettled('a', 120, QUIET, MAX)).toBe(0); + expect(gate.timeUntilSettled('a', 500, QUIET, MAX)).toBe(0); + }); + + it('restarts the quiet window when the size changes (grow)', () => { + const gate = new RevealGate(); + + gate.note('a', 120, 0); + // Grows to its real height mid-window; the clock restarts so it can't + // be revealed at the transient smaller size. + gate.note('a', 456, 80); + + expect(gate.timeUntilSettled('a', 120, QUIET, MAX)).toBe(80); + expect(gate.timeUntilSettled('a', 200, QUIET, MAX)).toBe(0); + }); + + it('ignores sub-pixel jitter (does not restart the window)', () => { + const gate = new RevealGate(); + + gate.note('a', 456, 0); + gate.note('a', 456.4, 80); + + expect(gate.timeUntilSettled('a', 120, QUIET, MAX)).toBe(0); + }); + + it('force-settles after the max window even while still changing', () => { + const gate = new RevealGate(); + + gate.note('a', 100, 0); + gate.note('a', 200, 500); + gate.note('a', 300, 1000); + + // Never quiet for QUIET ms, but MAX ms elapsed since first seen. + expect(gate.timeUntilSettled('a', 1000, QUIET, MAX)).toBe(0); + }); + + it('takes the sooner of the quiet and max deadlines', () => { + const gate = new RevealGate(); + + gate.note('a', 100, 0); + gate.note('a', 200, 950); + + // quiet would finish at 950+120=1070; max finishes at 0+1000=1000. + expect(gate.timeUntilSettled('a', 950, QUIET, MAX)).toBe(50); + }); + + it('stays settled once revealed, even when the size changes later', () => { + const gate = new RevealGate(); + + gate.note('a', 456, 0); + gate.markRevealed('a'); + + // A background refresh re-measures the already-visible row to a new + // height; it must not re-gate (hiding it would drop focus). + gate.note('a', 500, 1000); + + expect(gate.timeUntilSettled('a', 1000, QUIET, MAX)).toBe(0); + }); + + it('forgets a key so a recycled slot re-gates from scratch', () => { + const gate = new RevealGate(); + + gate.note('a', 456, 0); + gate.markRevealed('a'); + gate.forget('a'); + + expect(gate.timeUntilSettled('a', 0, QUIET, MAX)).toBe(Infinity); + }); + + it('reveals a final-marked key immediately, skipping the quiet window', () => { + const gate = new RevealGate(); + + gate.note('a', 456, 0); + // Yoga reported layout settled — the size is authoritative, no quiet wait. + expect(gate.markFinal('a')).toBe(true); + + expect(gate.timeUntilSettled('a', 0, QUIET, MAX)).toBe(0); + }); + + it('markFinal returns false the second time (already final)', () => { + const gate = new RevealGate(); + + gate.note('a', 456, 0); + + expect(gate.markFinal('a')).toBe(true); + expect(gate.markFinal('a')).toBe(false); + }); + + it('forget clears the final flag so a recycled slot re-gates', () => { + const gate = new RevealGate(); + + gate.note('a', 456, 0); + gate.markFinal('a'); + gate.forget('a'); + + expect(gate.timeUntilSettled('a', 0, QUIET, MAX)).toBe(Infinity); + }); +}); diff --git a/packages/react-lightning-components/src/components/VirtualList/RevealGate.ts b/packages/react-lightning-components/src/components/VirtualList/RevealGate.ts new file mode 100644 index 00000000..3bd1377d --- /dev/null +++ b/packages/react-lightning-components/src/components/VirtualList/RevealGate.ts @@ -0,0 +1,105 @@ +/** + * Tracks how long each cell's measured main-axis size has held steady. + * + * A row measures bottom-up and async: it reports a small placeholder size + * first, then grows to its real height once its content lays out. Revealing on + * the first report would show that grow (and shift the rows below it). This gate + * lets the list keep a cell hidden until its size has been quiet for a window, + * so it appears once, already at its final height. + */ +export class RevealGate { + private readonly _size = new Map(); + private readonly _stableSince = new Map(); + private readonly _firstSeenAt = new Map(); + private readonly _revealed = new Set(); + /** Keys whose size Yoga has reported settled (authoritative, skip the quiet window). */ + private readonly _final = new Set(); + + /** Record a measured size for a key. Restarts the quiet window on any real change. */ + note(key: string, size: number, now: number): void { + const prev = this._size.get(key); + + if (prev != null && Math.abs(prev - size) < 1) { + return; + } + + this._size.set(key, size); + this._stableSince.set(key, now); + + if (!this._firstSeenAt.has(key)) { + this._firstSeenAt.set(key, now); + } + } + + /** + * Latch a key as revealed once it has painted. The gate only guards a cell's + * first appearance; a later re-measure (e.g. a background refresh of an + * already-visible row) must NOT hide it again — hiding sets alpha 0, which + * drops focusability and throws spatial nav off the row. + */ + markRevealed(key: string): void { + this._revealed.add(key); + } + + /** + * Mark a key's size as authoritative because Yoga reported layout settled + * (converged to a fixpoint), so `timeUntilSettled` skips the quiet window. + * Returns true only the first time, so the caller can re-render to paint it. + */ + markFinal(key: string): boolean { + if (this._final.has(key)) { + return false; + } + + this._final.add(key); + + return true; + } + + /** + * ms until the key counts as settled: 0 once it has been revealed, otherwise + * the sooner of the quiet window elapsing since the last change and the max + * window since first seen (the backstop for content that never stops + * changing). `Infinity` until the key has been measured at all. + */ + timeUntilSettled( + key: string, + now: number, + quietMs: number, + maxMs: number, + ): number { + // A fixpoint-settled size is authoritative: reveal without the quiet + // window. `revealed` latches after paint; `final` short-circuits before it. + if (this._revealed.has(key) || this._final.has(key)) { + return 0; + } + + const stableSince = this._stableSince.get(key); + + if (stableSince == null) { + return Infinity; + } + + const firstSeenAt = this._firstSeenAt.get(key) ?? stableSince; + const quietRemaining = Math.max(0, quietMs - (now - stableSince)); + const forcedRemaining = Math.max(0, maxMs - (now - firstSeenAt)); + + return Math.min(quietRemaining, forcedRemaining); + } + + forget(key: string): void { + this._size.delete(key); + this._stableSince.delete(key); + this._firstSeenAt.delete(key); + this._revealed.delete(key); + this._final.delete(key); + } + + clear(): void { + this._size.clear(); + this._stableSince.clear(); + this._firstSeenAt.clear(); + this._revealed.clear(); + this._final.clear(); + } +} diff --git a/packages/react-lightning-components/src/components/VirtualList/VirtualList.md b/packages/react-lightning-components/src/components/VirtualList/VirtualList.md index 52efd5b2..d2990d59 100644 --- a/packages/react-lightning-components/src/components/VirtualList/VirtualList.md +++ b/packages/react-lightning-components/src/components/VirtualList/VirtualList.md @@ -17,7 +17,7 @@ When the VL is rendered outside any flex parent (`useIsInFlex() === false`), no When the VL is rendered inside a flex parent (`useIsInFlex() === true`), yoga is already laying out the surrounding tree. In this mode each cell wraps its content in a `FlexRoot`, which: - gives the user's `renderItem` real flex layout (children can `flexGrow`, `flexDirection`, etc.), -- is **unpinned on both axes** so yoga shrinks-to-fit content (see [Cell rendering](#cell-rendering) for why), +- is **unpinned on the main axis** so yoga shrinks-to-fit content there; the cross axis is pinned to `cellCrossSize` when the VL's cross size is definite (explicit/parent/outer-allocated) and left unpinned when it's content-derived (see [Cell rendering](#cell-rendering) for why), - emits `onResize` whenever its natural main-axis or cross-axis size changes. The cell forwards the main-axis size to `LayoutManager.reportItemSize(userKey, size)` (drives per-item layout offsets). The cross-axis size is forwarded separately to VL's `maxContentCross` aggregator (a monotonic, reset-on-data-change fallback for `viewportCrossSize` — see [Viewport resolution](#viewport-resolution)). @@ -32,11 +32,11 @@ The crucial discipline is that the cross-axis aggregation is **monotonic** (only **Three responsibilities:** -| File | Responsibility | -| - | - | -| `LayoutManager.ts` | Pure layout math. Given data + sizes + cross-axis size, computes per-item offsets in O(n). | +| File | Responsibility | +| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `LayoutManager.ts` | Pure layout math. Given data + sizes + cross-axis size, computes per-item offsets in O(n). | | `VirtualListCell.tsx` | One `` per visible cell with explicit absolute position and dimensions. Wraps user content in `VLCellKeyContext` + `CellBoundsContext` providers. The renderItem subtree persists across slot recycles — the cell wrapper _and_ its descendants survive userKey changes; nested VLs read the new userKey via `VLCellKeyContext` and run their cellKey-change branch instead of remounting. | -| `VirtualList.tsx` | Viewport derivation, scroll/focus state, recycling, the React glue. | +| `VirtualList.tsx` | Viewport derivation, scroll/focus state, recycling, the React glue. | Supporting modules: `useScrollHandler.ts` (scroll math, animation, focus-driven scroll), `useViewability.ts` (onViewableItemsChanged), `RecyclerPool.ts` (slot reuse by item type), `parseContentStyle.ts` (RN-style padding props), `VirtualListContext.ts` (the three React contexts). @@ -84,6 +84,7 @@ Supporting modules: `useScrollHandler.ts` (scroll math, animation, focus-driven - **`onLoad`** — fires once when first items render (with elapsed ms since mount). - **`onLayout`** — fires when content dimensions change. - **`autoFocus` / `trapFocus{Up,Right,Down,Left}`** — forwarded to the FocusGroup wrapping the list. +- **`skipChildFocusScroll?: boolean`** (default `false`) — opt out of VL's internal focus-follow scroll. When set, a focused child crossing a cell boundary still resolves and persists `focusedIndex`, but VL does not scroll the cell into view; the caller owns scrolling (e.g. drives `scrollToIndex` from its own authoritative focused index). Leaving VL's position-based follow on while the app also follows makes them fight — VL reads a just-recycled cell's not-yet-committed position as ~0 and snaps the row back to the start. ### Imperative — `VirtualListRef` @@ -92,6 +93,7 @@ Supporting modules: `useScrollHandler.ts` (scroll math, animation, focus-driven - `scrollToEnd({ animated? })` - `getScrollOffset()` - `getVisibleRange()` +- `getLayout(index)` — scroll-space `{ x, y, width, height }` of the item at `index` (or `undefined` if out of range). Mirrors FlashList's per-item layout query; for callers that interpolate row positions against the scroll offset (crossfade/parallax). Coordinates are in the content container's space: main axis past the leading padding + header, cross axis past the cross padding. --- @@ -186,7 +188,7 @@ For a list with no explicit cross AND no flex ancestor (pinned mode), no measure ref={cellElementRef} autoFocus={shouldFocus} style={{ - position: 'absolute', + position: "absolute", x, y, // Both axes pinned by VL — cell wrapper has NO flex of its own. @@ -195,18 +197,24 @@ For a list with no explicit cross AND no flex ancestor (pinned mode), no measure }} > {isInFlex ? ( - /* FlexRoot is unpinned on both axes — yoga shrinks-to-fit content. + /* FlexRoot is unpinned on the main axis — yoga shrinks-to-fit content. + The cross axis is pinned to crossSize when pinCrossAxis (definite + viewport cross), so flex children can fill the cell width/height. handleResize forwards main-axis to onItemSizeChange (LM per-key store) and cross-axis to onContentCrossLayout (VL maxContentCross). */ - + - {renderedItem} + + {renderedItem} + ) : ( /* plain content — no flex, no measurement */ - {renderedItem} + + {renderedItem} + )} {/* optional separator, position:absolute */} @@ -227,7 +235,7 @@ When the caller's `renderItem` includes an inner focusable, that inner is added **Why FlexRoot is conditional on `isInFlex`.** When the VL has no flex ancestor, no yoga is running in this subtree. Adding a FlexRoot just for measurement would force yoga to spin up — pure overhead with no benefit, since the user's content isn't using flex either. So we skip it; the cell is silent and pinned. -**Why FlexRoot is unpinned on both axes.** Yoga shrinks the FlexRoot to fit content on both axes. The cell forwards both dimensions: main goes into `LayoutManager`'s per-key measurement store; cross feeds VL's `maxContentCross` fallback (used only when no explicit cross source is available). Pinning cross to `cellCrossSize` would create the prior architecture's feedback loop — cell echoes its own pinned size back to VL, which uses that to compute the pin, etc. Leaving both unpinned lets cell content drive sizing without a loop. Tradeoff: flex-percentage layouts on cross axis (e.g. `width: '100%'` inside a horizontal VL's cell) won't work because the parent has no fixed cross dim — callers needing those should set `style.h` (or `.w`) on the VL, which flips the chain into the explicit branch. +**Why FlexRoot's main axis is unpinned (and the cross axis only conditionally pinned).** Yoga shrinks the FlexRoot to fit content on the main axis; that measurement goes into `LayoutManager`'s per-key store. The cross axis is pinned to `crossSize` when the viewport cross resolved from a definite source (explicit `style`, parent cell bounds, or the flex-allocated outer size — `pinCrossAxis`), so flex children can fill the cell like they would a native list cell. When the cross size is content-derived (a horizontal VL with no explicit `style.h`), pinning would create the prior architecture's feedback loop — cell echoes its own pinned size back to VL, which uses that to compute the pin, etc. — so those cells stay unpinned and `maxContentCross` keeps driving the viewport cross. Tradeoff while unpinned: flex-percentage layouts on the cross axis (e.g. `width: '100%'` inside a horizontal VL's cell) won't work because the parent has no fixed cross dim — callers needing those should set `style.h` (or `.w`) on the VL, which flips the chain into the explicit (pinned) branch. **Why measure via `onResize`?** Lightning's universal `NodeResizeObserver` fires `onResize` whenever a node's size changes. The cell reports the main-axis number to `onItemSizeChange` (LayoutManager's per-key store) and the cross-axis number to `onContentCrossLayout` (VL's `maxContentCross` aggregator). Zero/negative reports are filtered before they reach VL — a transient FlexRoot zero during recycle would otherwise pollute the cache. diff --git a/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx b/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx index e9f00d5c..8488a4a0 100644 --- a/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx +++ b/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx @@ -1,9 +1,9 @@ import type { ComponentType, Ref } from 'react'; import { type ForwardedRef, + type ReactElement, forwardRef, isValidElement, - type ReactElement, useContext, useEffect, useImperativeHandle, @@ -11,27 +11,45 @@ import { useRef, useState, } from 'react'; - import { FocusGroup, type LightningElement, type LightningViewElementStyle, } from '@plextv/react-lightning'; -import { FlexBoundary, useIsInFlex } from '@plextv/react-lightning-plugin-flexbox'; - +import { + FlexBoundary, + FlexRoot, + useIsInFlex, +} from '@plextv/react-lightning-plugin-flexbox'; import { LayoutManager } from './LayoutManager'; -import { parseContentStyle } from './parseContentStyle'; import { RecyclerPool } from './RecyclerPool'; -import { useScrollHandler } from './useScrollHandler'; -import { useViewability } from './useViewability'; +import { RevealGate } from './RevealGate'; import { VirtualListCell } from './VirtualListCell'; import { CellBoundsContext, - type VLPersistedState, VLCellKeyContext, + type VLPersistedState, VLStateCacheContext, } from './VirtualListContext'; import type { VirtualListProps, VirtualListRef } from './VirtualListTypes'; +import { capSelfMeasuredViewport } from './capSelfMeasuredViewport'; +import { computeItemRect } from './computeItemRect'; +import { parseContentStyle } from './parseContentStyle'; +import { resolveCrossSize } from './resolveCrossSize'; +import { resolveRevealBoundary } from './resolveRevealBoundary'; +import { resolveSectionSize } from './resolveSectionSize'; +import { resolveVisibleMainSpan } from './resolveVisibleMainSpan'; +import { useScrollHandler } from './useScrollHandler'; +import { useViewability } from './useViewability'; + +// A cell reveals once its size has held steady this long — matches the +// LayoutManager's own stability window, so a size that's been quiet this long +// has no pending change left in flight. +const REVEAL_QUIET_MS = 120; +// Backstop so content that never stops resizing still reveals eventually. +const REVEAL_MAX_MS = 1000; +// Wake a touch after the computed deadline so the quiet window is safely past. +const REVEAL_CHECK_SLOP_MS = 8; function renderListComponent( component: VirtualListProps['ListHeaderComponent'], @@ -49,11 +67,13 @@ function renderListComponent( return ; } -function VirtualListInner(props: VirtualListProps, ref: ForwardedRef) { +function VirtualListInner( + props: VirtualListProps, + ref: ForwardedRef, +) { const { data, renderItem, - estimatedItemSize = 200, horizontal = false, numColumns = 1, drawDistance = 250, @@ -85,6 +105,7 @@ function VirtualListInner(props: VirtualListProps, ref: ForwardedRef(props: VirtualListProps, ref: ForwardedRef(props: VirtualListProps, ref: ForwardedRef>(() => new Map()); + const [ownStateCache] = useState>( + () => new Map(), + ); const [measuredSize, setMeasuredSize] = useState({ w: 0, h: 0 }); + // Visible main-axis span from the list's stage position to the stage edge, + // tracked on resize. Caps the self-measured viewport fallback below. + const outerElementRef = useRef(null); + const [visibleMainSpan, setVisibleMainSpan] = useState(0); const [, setLayoutVersion] = useState(0); const [separatorSize, setSeparatorSize] = useState(0); const separatorSizeRef = useRef(0); + const [measuredHeaderSize, setMeasuredHeaderSize] = useState(0); + const measuredHeaderSizeRef = useRef(0); + const [measuredFooterSize, setMeasuredFooterSize] = useState(0); + const measuredFooterSizeRef = useRef(0); // Monotonic per-dataset: once a cell reports cross=N, stays at N or // larger until data/extraData identity changes. const [maxContentCross, setMaxContentCross] = useState(0); const maxContentCrossRef = useRef(0); + // Bumped whenever the cross measurement is reset, to make mounted cells + // re-push their current cross so it climbs back (see the reset effect). + const [crossGeneration, setCrossGeneration] = useState(0); + // A row measures bottom-up and async: it reports a placeholder size, then + // grows to its real height. `revealGate` tracks how long each cell's size has + // held steady so the list can keep a cell hidden until it settles, then paint + // it once at its final height instead of on-screen growing (which shoves the + // rows below it down). Starts at -1: nothing paints until the gate settles it. + const [revealGate] = useState(() => new RevealGate()); + const [revealThrough, setRevealThrough] = useState(-1); + const revealTimerRef = useRef | undefined>( + undefined, + ); const padding = parseContentStyle(contentContainerStyle); const paddingStart = horizontal ? padding.left : padding.top; @@ -120,52 +166,61 @@ function VirtualListInner(props: VirtualListProps, ref: ForwardedRef parent cell bounds > self-measured. const explicitMain = horizontal ? (style?.w as number | undefined) : (style?.h as number | undefined); - const parentMain = horizontal ? parentCellBounds?.width : parentCellBounds?.height; + const parentMain = horizontal + ? parentCellBounds?.width + : parentCellBounds?.height; const measuredOuterMain = horizontal ? measuredSize.w : measuredSize.h; const viewportSize = - explicitMain ?? parentMain ?? (measuredOuterMain > 0 ? measuredOuterMain : 0); + explicitMain ?? + parentMain ?? + capSelfMeasuredViewport(measuredOuterMain, visibleMainSpan); const explicitCross = horizontal ? (style?.h as number | undefined) : (style?.w as number | undefined); - const parentCross = horizontal ? parentCellBounds?.height : parentCellBounds?.width; + const parentCross = horizontal + ? parentCellBounds?.height + : parentCellBounds?.width; const measuredOuterCross = horizontal ? measuredSize.h : measuredSize.w; - let viewportCrossSize: number; - - // Cross-axis priority differs by orientation. Vertical: parent/measured - // cross is reliable (parent flex allocates column width). Horizontal: - // parent/measured cross is the OUTER cell's full height (title + this VL - // + siblings) which is bigger than the cards themselves — prefer - // content-driven `maxContentCross` and only fall back when no content - // has measured yet. Without the asymmetry the cells oscillate as the - // outer cell's measured height churns during scroll/focus animations. - if (explicitCross != null && explicitCross > 0) { - viewportCrossSize = explicitCross; - } else if (!horizontal && parentCross != null && parentCross > 0) { - viewportCrossSize = parentCross; - } else if (!horizontal && measuredOuterCross > 0) { - viewportCrossSize = measuredOuterCross; - } else if (maxContentCross > 0) { - viewportCrossSize = maxContentCross + crossPadding; - } else if (parentCross != null && parentCross > 0) { - viewportCrossSize = parentCross; - } else if (measuredOuterCross > 0) { - viewportCrossSize = measuredOuterCross; - } else { - viewportCrossSize = estimatedItemSize; - } + const { viewportCrossSize, isDefinite: crossSizeIsDefinite } = + resolveCrossSize({ + horizontal, + explicitCross, + parentCross, + measuredOuterCross, + maxContentCross, + crossPadding, + }); const cellCrossSize = (viewportCrossSize - crossPadding) / numColumns; + // Header/footer span the full cell area (all columns). Pin their FlexRoot's + // cross axis under the same definiteness rule as the cells, so flex content + // (e.g. a stretch Column) fills the list width instead of shrink-fitting. + const sectionCrossSize = viewportCrossSize - crossPadding; + const sectionFlexStyle = crossSizeIsDefinite + ? horizontal + ? { h: sectionCrossSize } + : { w: sectionCrossSize } + : undefined; + // Lazy-init the LayoutManager via useState — the previous // `useRef(null) + if (!ref.current) ref.current = new ...` pattern is a // render-phase ref read AND write, which causes React Compiler to bail @@ -175,7 +230,6 @@ function VirtualListInner(props: VirtualListProps, ref: ForwardedRef>(() => { const lm = new LayoutManager({ data, - estimatedItemSize, numColumns, overrideItemLayout, extraData, @@ -199,7 +253,6 @@ function VirtualListInner(props: VirtualListProps, ref: ForwardedRef(props: VirtualListProps, ref: ForwardedRef(props: VirtualListProps, ref: ForwardedRef(() => new RecyclerPool()); const getKey = (index: number): string => - keyExtractor && data[index] !== undefined ? keyExtractor(data[index], index) : String(index); + keyExtractor && data[index] !== undefined + ? keyExtractor(data[index], index) + : String(index); const getData = (i: number) => data[i]; const getLayout = (i: number) => layoutManager.getLayout(i); const totalContentSize = - paddingStart + headerSize + layoutManager.totalSize + footerSize + paddingEnd; + paddingStart + + headerSize + + layoutManager.totalSize + + footerSize + + paddingEnd; const finalCross = - viewportCrossSize > 0 ? viewportCrossSize : cellCrossSize * numColumns + crossPadding; + viewportCrossSize > 0 + ? viewportCrossSize + : cellCrossSize * numColumns + crossPadding; // While true, contentStyle omits x/y so reconciliation can't clobber the // imperative scroll animation in flight. @@ -240,6 +300,7 @@ function VirtualListInner(props: VirtualListProps, ref: ForwardedRef { setIsScrollAnimating(false); @@ -251,8 +312,20 @@ function VirtualListInner(props: VirtualListProps, ref: ForwardedRef { - if (layoutManager.reportItemSize(userKey, measuredSize)) { + const handleItemSizeChange = ( + userKey: string, + measuredSize: number, + final = false, + ) => { + let changed = layoutManager.reportItemSize(userKey, measuredSize, final); + + // A settled (final) size is authoritative — reveal it without the quiet + // window. markFinal returns true only the first time, so this bumps once. + if (final && revealGate.markFinal(userKey)) { + changed = true; + } + + if (changed) { setLayoutVersion((v) => v + 1); } }; @@ -277,6 +350,24 @@ function VirtualListInner(props: VirtualListProps, ref: ForwardedRef { + const main = horizontal ? event.w : event.h; + + if (main > 0 && Math.abs(main - measuredHeaderSizeRef.current) >= 1) { + measuredHeaderSizeRef.current = main; + setMeasuredHeaderSize(main); + } + }; + + const handleFooterLayout = (event: { w: number; h: number }) => { + const main = horizontal ? event.w : event.h; + + if (main > 0 && Math.abs(main - measuredFooterSizeRef.current) >= 1) { + measuredFooterSizeRef.current = main; + setMeasuredFooterSize(main); + } + }; + const { contentRef, scrollOffsetRef, @@ -318,7 +409,13 @@ function VirtualListInner(props: VirtualListProps, ref: ForwardedRef(props: VirtualListProps, ref: ForwardedRef(props: VirtualListProps, ref: ForwardedRef(props: VirtualListProps, ref: ForwardedRef { if (skipNextFocus) { setSkipNextFocus(false); - handleChildFocused(child); + + if (!skipChildFocusScroll) { + handleChildFocused(child); + } return; } @@ -397,7 +498,9 @@ function VirtualListInner(props: VirtualListProps, ref: ForwardedRef= 0) { setFocusedIndex(resolvedIdx); @@ -413,7 +516,11 @@ function VirtualListInner(props: VirtualListProps, ref: ForwardedRef 0 && visibleRange.endIndex >= visibleRange.startIndex) { @@ -434,7 +541,84 @@ function VirtualListInner(props: VirtualListProps, ref: ForwardedRef + // Reveal gate: paint the visible cells only up to the first one whose size + // hasn't settled, so a growing row stays hidden until it reaches its final + // height. Runs after every commit (measurements arrive between renders) and + // schedules a re-check for the soonest pending cell. Fixed-size lists + // (override-pinned cells) are all exempt, so this is a no-op there. + useLayoutEffect(() => { + const now = Date.now(); + const order: number[] = []; + + for (const index of visibleIndices) { + const item = data[index]; + + if (item == null) { + continue; + } + + const layout = layoutManager.getLayout(index); + + if (!layout || layout.size === 0) { + continue; + } + + order.push(index); + + if (layoutManager.isMeasured(index)) { + revealGate.note(getKey(index), layout.size, now); + } + } + + const { revealThrough: nextRevealThrough, nextCheckMs } = + resolveRevealBoundary( + order, + (index) => layoutManager.hasOverrideSize(index), + (index) => + layoutManager.isMeasured(index) + ? revealGate.timeUntilSettled( + getKey(index), + now, + REVEAL_QUIET_MS, + REVEAL_MAX_MS, + ) + : Infinity, + ); + + // Latch every cell up to the boundary as revealed so a later re-measure + // (background refresh of an already-visible row) can't hide it again. + for (const index of order) { + if (index > nextRevealThrough) { + break; + } + + revealGate.markRevealed(getKey(index)); + } + + setRevealThrough((prev) => + prev === nextRevealThrough ? prev : nextRevealThrough, + ); + + if (revealTimerRef.current != null) { + clearTimeout(revealTimerRef.current); + revealTimerRef.current = undefined; + } + + if (Number.isFinite(nextCheckMs) && nextCheckMs > 0) { + revealTimerRef.current = setTimeout(() => { + setLayoutVersion((v) => v + 1); + }, nextCheckMs + REVEAL_CHECK_SLOP_MS); + } + + return () => { + if (revealTimerRef.current != null) { + clearTimeout(revealTimerRef.current); + revealTimerRef.current = undefined; + } + }; + }); + + const getType = (index: number): number | string => // oxlint-disable-next-line typescript/no-non-null-assertion -- index is within data bounds getItemType?.(data[index]!, index, extraData) ?? 0; @@ -444,23 +628,65 @@ function VirtualListInner(props: VirtualListProps, ref: ForwardedRef { maxContentCrossRef.current = 0; setMaxContentCross(0); + setCrossGeneration((g) => g + 1); // oxlint-disable-next-line react-hooks/exhaustive-deps -- intentional reset on data identity change }, [data, extraData]); const handleViewportResize = (event: { w: number; h: number }) => { - setMeasuredSize((prev) => (prev.w === event.w && prev.h === event.h ? prev : event)); + setMeasuredSize((prev) => + prev.w === event.w && prev.h === event.h ? prev : event, + ); + + // A canvas-overflowing (or overflow-margin-inflated) list is flex-sized + // past the screen; capSelfMeasuredViewport needs the visible span to rein + // the viewport back to what is on screen. Horizontal too: the centered + // switch-user row inflates its width via a negative right margin. + const el = outerElementRef.current; + + if (el) { + const root = el.rootElement; + const pos = el.getRelativePosition(root); + const span = resolveVisibleMainSpan( + horizontal, + root.node.w, + root.node.h, + pos.x, + pos.y, + ); + + setVisibleMainSpan((prev) => (prev === span ? prev : span)); + } }; useImperativeHandle(ref, () => ({ scrollToIndex: (params) => - scrollToIndex(params.index, params.animated, params.viewPosition, params.viewOffset), + scrollToIndex( + params.index, + params.animated, + params.viewPosition, + params.viewOffset, + ), scrollToOffset: (params) => scrollToOffset(params.offset, params.animated), scrollToEnd: (params) => scrollToEnd(params?.animated), getScrollOffset: () => scrollOffsetRef.current, getVisibleRange: () => visibleRange, + getLayout: (index) => { + const layout = layoutManager.getLayout(index); + + return layout + ? computeItemRect(layout, itemAreaOffset, paddingCross, horizontal) + : undefined; + }, })); const loadTimeRef = useRef(Date.now()); @@ -511,19 +737,21 @@ function VirtualListInner(props: VirtualListProps, ref: ForwardedRef {renderListComponent(ListEmptyComponent)} @@ -554,13 +782,13 @@ function VirtualListInner(props: VirtualListProps, ref: ForwardedRef(props: VirtualListProps, ref: ForwardedRef revealThrough && focusedIndex !== index} onContentCrossLayout={handleContentCrossLayout} + onItemEmpty={handleItemEmpty} + onItemSizeChange={handleItemSizeChange} onSeparatorLayout={handleSeparatorLayout} /> ); @@ -601,19 +832,20 @@ function VirtualListInner(props: VirtualListProps, ref: ForwardedRef - {ListHeaderComponent && ( + {ListHeaderComponent ? ( (props: VirtualListProps, ref: ForwardedRef - {renderListComponent(ListHeaderComponent)} + {isInFlex ? ( + + {renderListComponent(ListHeaderComponent)} + + ) : ( + renderListComponent(ListHeaderComponent) + )} - )} + ) : null} {cells} - {ListFooterComponent && ( + {ListFooterComponent ? ( (props: VirtualListProps, ref: ForwardedRef - {renderListComponent(ListFooterComponent)} + {isInFlex ? ( + + {renderListComponent(ListFooterComponent)} + + ) : ( + renderListComponent(ListFooterComponent) + )} - )} + ) : null} diff --git a/packages/react-lightning-components/src/components/VirtualList/VirtualListCell.tsx b/packages/react-lightning-components/src/components/VirtualList/VirtualListCell.tsx index 441aad55..6baa7ea3 100644 --- a/packages/react-lightning-components/src/components/VirtualList/VirtualListCell.tsx +++ b/packages/react-lightning-components/src/components/VirtualList/VirtualListCell.tsx @@ -1,10 +1,11 @@ import type { ReactElement } from 'react'; import { memo, useLayoutEffect, useRef } from 'react'; - -import type { LightningElement, LightningViewElementStyle } from '@plextv/react-lightning'; +import type { + LightningElement, + LightningViewElementStyle, +} from '@plextv/react-lightning'; import { FocusGroup, useFocusManager } from '@plextv/react-lightning'; -import { FlexRoot } from '@plextv/react-lightning-plugin-flexbox'; - +import { FlexRoot, onFlexLayoutSettled } from '@plextv/react-lightning-plugin-flexbox'; import { CellBoundsContext, VLCellKeyContext } from './VirtualListContext'; import type { VirtualListCellProps } from './VirtualListTypes'; @@ -23,6 +24,9 @@ const VirtualListCellInner = ({ isLastItem, ItemSeparatorComponent, isInFlex, + crossGeneration, + pinCrossAxis = false, + withholdPaint = false, onItemSizeChange, onItemEmpty, onContentCrossLayout, @@ -35,6 +39,9 @@ const VirtualListCellInner = ({ y: horizontal ? crossOffset : mainOffset, w: horizontal ? size : crossSize, h: horizontal ? crossSize : size, + // Always explicit: a Lightning style key set to undefined isn't repainted, + // so revealing must push alpha 1, not drop the key. + alpha: withholdPaint ? 0 : 1, }; const cellBounds = { @@ -44,9 +51,17 @@ const VirtualListCellInner = ({ const flexRootRef = useRef(null); const cellElementRef = useRef(null); + const lastMainRef = useRef(0); + const lastCrossRef = useRef(0); const prevShouldFocusRef = useRef(shouldFocus); const focusManager = useFocusManager(); - const renderedItem = renderItem?.({ item, index, extraData, target: 'Cell', shouldFocus }); + const renderedItem = renderItem?.({ + item, + index, + extraData, + target: 'Cell', + shouldFocus, + }); const isEmpty = renderedItem == null; // Imperative focus claim on shouldFocus false → true. Mount-time claims @@ -90,10 +105,13 @@ const VirtualListCellInner = ({ } }, [isEmpty, userKey]); - // One-shot push on userKey change for the same-size-recycle case: - // NodeResizeObserver stays silent when content lays out at the previous - // occupant's size, but LM still needs the new userKey's measurement. - // RAF defers past yoga's layout pass so node.w/h are post-render. + // Read the cell's final size off Yoga's `settled` signal (layout converged to + // a fixpoint), not a frame timer. `settled` fires after the whole grow chain + // is done, so the size is authoritative — reported `final` to skip dampening + // and the reveal quiet window. Subscribed before the first layout pass runs + // (markFlexRoot queues it as a microtask), so no settle is missed; stays + // subscribed to catch later re-converges (background refresh). Worker mode + // never emits `settled`, so this no-ops and the onResize path takes over. // oxlint-disable-next-line react-hooks/exhaustive-deps -- onItemSizeChange/onContentCrossLayout // omitted: see the previous effect for the rationale. useLayoutEffect(() => { @@ -101,7 +119,10 @@ const VirtualListCellInner = ({ return; } - const rafId = requestAnimationFrame(() => { + lastMainRef.current = 0; + lastCrossRef.current = 0; + + return onFlexLayoutSettled(() => { const node = flexRootRef.current?.node; if (!node) { @@ -111,21 +132,21 @@ const VirtualListCellInner = ({ const main = horizontal ? node.w : node.h; const cross = horizontal ? node.h : node.w; - if (main > 0) { - onItemSizeChange(userKey, main); + if (main > 0 && Math.abs(main - lastMainRef.current) >= 1) { + lastMainRef.current = main; + onItemSizeChange(userKey, main, true); } - if (cross > 0) { + if (cross > 0 && Math.abs(cross - lastCrossRef.current) >= 1) { + lastCrossRef.current = cross; onContentCrossLayout?.(cross); } }); + }, [userKey, isInFlex, isEmpty, horizontal, crossGeneration]); - return () => { - cancelAnimationFrame(rafId); - }; - }, [userKey, isInFlex, isEmpty, horizontal]); - - const separatorPosition: { x: number } | { y: number } = horizontal ? { x: size } : { y: size }; + const separatorPosition: { x: number } | { y: number } = horizontal + ? { x: size } + : { y: size }; // Return null AFTER the hooks so we don't paint an empty cell wrapper. // The empty-row effect above signals LM via `onItemEmpty` so the row @@ -136,16 +157,26 @@ const VirtualListCellInner = ({ const innerContent = ( - {renderedItem} + + {renderedItem} + ); - // FlexRoot is unpinned on both axes so yoga shrinks-to-fit content; - // pinning the cross axis would create a cell→VL→cell feedback loop. - // Tradeoff: cross-axis percentages (`width: '100%'` in a vertical VL - // cell) need the caller to set `style.h`/`.w` on the VL. + // When the VL's cross size is definite (external, not content-derived) the + // cell pins the FlexRoot's cross axis so flex children can fill it, like a + // native list cell spanning the list width. A content-derived cross size + // must stay unpinned or it freezes at the estimate before content reports + // its real size (cell→VL→cell feedback loop). While unpinned, cross-axis + // percentages (`width: '100%'` in a vertical VL cell) need the caller to + // set `style.h`/`.w` on the VL. + const flexRootStyle: LightningViewElementStyle | null = pinCrossAxis + ? horizontal + ? { h: crossSize } + : { w: crossSize } + : null; const measuredContent = isInFlex ? ( - + {innerContent} ) : ( @@ -173,12 +204,19 @@ const VirtualListCellInner = ({ ); separatorEl = ( - {separatorContent} + + {separatorContent} + ); } return ( - + {measuredContent} {separatorEl} @@ -209,12 +247,17 @@ function areCellPropsEqual( prev.isLastItem === next.isLastItem && prev.ItemSeparatorComponent === next.ItemSeparatorComponent && prev.isInFlex === next.isInFlex && + prev.pinCrossAxis === next.pinCrossAxis && + prev.withholdPaint === next.withholdPaint && prev.pooled === next.pooled ); } -export const VirtualListCell = memo(VirtualListCellInner, areCellPropsEqual) as (( - props: VirtualListCellProps, -) => ReactElement | null) & { displayName?: string }; +export const VirtualListCell = memo( + VirtualListCellInner, + areCellPropsEqual, +) as ((props: VirtualListCellProps) => ReactElement | null) & { + displayName?: string; +}; VirtualListCell.displayName = 'VirtualListCell'; diff --git a/packages/react-lightning-components/src/components/VirtualList/VirtualListTypes.ts b/packages/react-lightning-components/src/components/VirtualList/VirtualListTypes.ts index 0efc8ac8..00d9311a 100644 --- a/packages/react-lightning-components/src/components/VirtualList/VirtualListTypes.ts +++ b/packages/react-lightning-components/src/components/VirtualList/VirtualListTypes.ts @@ -1,5 +1,4 @@ import type { ComponentType, ReactElement } from 'react'; - import type { LightningViewElementStyle } from '@plextv/react-lightning'; export interface VirtualListRenderItemInfo { @@ -64,17 +63,20 @@ export interface ScrollEvent { /** * Only 'Cell' is currently supported. */ -export type RenderTarget = 'Cell' | 'StickyHeader' | 'Measurement'; +export type RenderTarget = 'Cell' | 'Measurement' | 'StickyHeader'; -export type VirtualListRenderItem = (info: VirtualListRenderItemInfo) => ReactElement | null; +export type VirtualListRenderItem = ( + info: VirtualListRenderItemInfo, +) => ReactElement | null; export interface VirtualListProps { /** Array of data items to render. */ - data: ReadonlyArray; + data: readonly T[]; /** Render function for each item. */ - renderItem: ((info: VirtualListRenderItemInfo) => ReactElement | null) | null | undefined; - /** Average or median item size. Used before items are measured. Default 200. */ - estimatedItemSize?: number; + renderItem: + | ((info: VirtualListRenderItemInfo) => ReactElement | null) + | null + | undefined; /** Scroll horizontally instead of vertically. */ horizontal?: boolean | null; /** Number of columns for grid layout. Default 1. */ @@ -107,7 +109,11 @@ export interface VirtualListProps { /** Override size or span per-item. Must be fast — called frequently. */ overrideItemLayout?: OverrideItemLayoutFn; /** Return a type for recycling pools. Items of same type reuse views. */ - getItemType?: (item: T, index: number, extraData?: unknown) => string | number | undefined; + getItemType?: ( + item: T, + index: number, + extraData?: unknown, + ) => number | string | undefined; /** Scroll to this index on mount. */ initialScrollIndex?: number | null; @@ -121,7 +127,10 @@ export interface VirtualListProps { onScroll?: (event: ScrollEvent) => void; /** Called when viewable items change. */ onViewableItemsChanged?: - | ((info: { viewableItems: ViewToken[]; changed: ViewToken[] }) => void) + | ((info: { + viewableItems: ViewToken[]; + changed: ViewToken[]; + }) => void) | null; /** Configuration for viewability tracking. */ viewabilityConfig?: ViewabilityConfig | null; @@ -132,7 +141,7 @@ export interface VirtualListProps { onLayout?: (rect: { w: number; h: number }) => void; /** Snap scroll alignment when focusing items. Default 'start'. */ - snapToAlignment?: 'start' | 'center' | 'end'; + snapToAlignment?: 'center' | 'end' | 'start'; /** Duration of scroll animations in ms. Default 300. */ animationDuration?: number; @@ -142,6 +151,26 @@ export interface VirtualListProps { trapFocusRight?: boolean; trapFocusDown?: boolean; trapFocusLeft?: boolean; + + /** + * Opt out of VirtualList's internal focus-follow scroll. When a focused + * child crosses a cell boundary VL still resolves and persists the focused + * index, but does NOT scroll the focused cell into view — the caller owns + * scrolling (e.g. a row that drives `scrollToIndex` from its own + * authoritative focused index). Leaving VL's position-based follow on while + * the app also follows makes the two fight: VL reads a just-recycled cell's + * not-yet-committed position as ~0 and snaps the row back to the start. + * Default `false` (VL follows focus itself). + */ + skipChildFocusScroll?: boolean; +} + +/** Scroll-space rectangle of an item, in the list's content coordinate space. */ +export interface ItemLayout { + x: number; + y: number; + width: number; + height: number; } export interface VirtualListRef { @@ -155,6 +184,13 @@ export interface VirtualListRef { scrollToEnd: (params?: { animated?: boolean }) => void; getScrollOffset: () => number; getVisibleRange: () => { startIndex: number; endIndex: number }; + /** + * Scroll-space rectangle of the item at `index`, or `undefined` if the + * index is out of range. Mirrors FlashList's per-item layout query — used + * by callers that interpolate row positions against the scroll offset + * (e.g. crossfade/parallax effects). + */ + getLayout: (index: number) => ItemLayout | undefined; } export interface VirtualListCellProps { @@ -174,9 +210,16 @@ export interface VirtualListCellProps { ItemSeparatorComponent?: ComponentType | null; /** True when a flex ancestor exists; cells wrap in FlexRoot for layout + measurement. False means pinned/silent. */ isInFlex: boolean; - onItemSizeChange?: (userKey: string, size: number) => void; + /** Pin the FlexRoot's cross axis to `crossSize` so flex children can fill it. Only safe when the VL's cross size is definite (not content-derived). */ + pinCrossAxis?: boolean; + /** Hold the cell invisible (alpha 0) until its size has settled, so it never grows on screen. Still mounts and measures while withheld. */ + withholdPaint?: boolean; + /** `final` = Yoga reported layout settled, so the size is authoritative (skip dampening + reveal now). */ + onItemSizeChange?: (userKey: string, size: number, final?: boolean) => void; /** Distinct from `onItemSizeChange(_, 0)` (rejected) — this is the explicit empty-row path. */ onItemEmpty?: (userKey: string) => void; + /** Bumped by the list to make the cell re-push its cross after a reset. */ + crossGeneration?: number; onContentCrossLayout?: (size: number) => void; onSeparatorLayout?: (size: number) => void; /** Mounted offscreen for state preservation; outer FG is disabled so spatial nav skips it. */ diff --git a/packages/react-lightning-components/src/components/VirtualList/capSelfMeasuredViewport.spec.ts b/packages/react-lightning-components/src/components/VirtualList/capSelfMeasuredViewport.spec.ts new file mode 100644 index 00000000..28ebea53 --- /dev/null +++ b/packages/react-lightning-components/src/components/VirtualList/capSelfMeasuredViewport.spec.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; + +import { capSelfMeasuredViewport } from './capSelfMeasuredViewport'; + +describe('capSelfMeasuredViewport', () => { + it('returns 0 when the outer element has not measured yet', () => { + expect(capSelfMeasuredViewport(0, 440)).toBe(0); + }); + + it('keeps the measured size when it fits within the visible span', () => { + expect(capSelfMeasuredViewport(400, 480)).toBe(400); + }); + + it('caps a content-sized measurement at the visible span', () => { + expect(capSelfMeasuredViewport(2400, 440)).toBe(440); + }); + + it('leaves the measured size uncapped when the visible span is unknown', () => { + // span 0: not measured yet. span < 0: the list starts past the stage edge. + expect(capSelfMeasuredViewport(2400, 0)).toBe(2400); + expect(capSelfMeasuredViewport(2400, -100)).toBe(2400); + }); +}); diff --git a/packages/react-lightning-components/src/components/VirtualList/capSelfMeasuredViewport.ts b/packages/react-lightning-components/src/components/VirtualList/capSelfMeasuredViewport.ts new file mode 100644 index 00000000..ca082153 --- /dev/null +++ b/packages/react-lightning-components/src/components/VirtualList/capSelfMeasuredViewport.ts @@ -0,0 +1,26 @@ +/** + * Caps a self-measured main-axis viewport at the list's visible span (stage + * edge minus the list's stage position). + * + * A list with no explicit main size and no parent cell bounds is flex-sized, + * and when nothing bounds it (the layout overflows the canvas) flex gives it + * its full content size. Using that as the viewport makes maxScroll 0, so the + * list renders everything and never scrolls to follow focus. Only the + * self-measured fallback is capped — explicit and parent-derived sizes are + * definite and stay trusted. + * + * A non-positive span means it is unknown (unmounted, or the list starts past + * the stage edge); the measurement passes through uncapped rather than + * collapsing the list. + */ +export function capSelfMeasuredViewport(measuredMain: number, visibleMainSpan: number): number { + if (measuredMain <= 0) { + return 0; + } + + if (visibleMainSpan <= 0) { + return measuredMain; + } + + return Math.min(measuredMain, visibleMainSpan); +} diff --git a/packages/react-lightning-components/src/components/VirtualList/computeItemRect.spec.ts b/packages/react-lightning-components/src/components/VirtualList/computeItemRect.spec.ts new file mode 100644 index 00000000..5d7910c2 --- /dev/null +++ b/packages/react-lightning-components/src/components/VirtualList/computeItemRect.spec.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; + +import { computeItemRect } from './computeItemRect'; +import type { ComputedLayout } from './LayoutManager'; + +const layout = (overrides: Partial = {}): ComputedLayout => ({ + offset: 0, + size: 0, + column: 0, + crossOffset: 0, + crossSize: 0, + ...overrides, +}); + +describe('computeItemRect', () => { + it('maps a vertical item: offset → y, crossOffset → x', () => { + const rect = computeItemRect( + layout({ offset: 300, size: 100, crossOffset: 20, crossSize: 400 }), + 50, // itemAreaOffset (paddingStart + header) + 10, // paddingCross + false, + ); + + expect(rect).toEqual({ x: 30, y: 350, width: 400, height: 100 }); + }); + + it('maps a horizontal item: offset → x, crossOffset → y', () => { + const rect = computeItemRect( + layout({ offset: 300, size: 100, crossOffset: 20, crossSize: 400 }), + 50, + 10, + true, + ); + + expect(rect).toEqual({ x: 350, y: 30, width: 100, height: 400 }); + }); + + it('includes the item area offset and cross padding in the origin', () => { + const rect = computeItemRect(layout({ offset: 0, size: 80, crossSize: 200 }), 120, 16, false); + + expect(rect.x).toBe(16); + expect(rect.y).toBe(120); + }); + + it('places a multi-column cell at its column cross offset', () => { + const rect = computeItemRect( + layout({ + offset: 100, + size: 100, + column: 1, + crossOffset: 200, + crossSize: 200, + }), + 0, + 0, + false, + ); + + expect(rect).toEqual({ x: 200, y: 100, width: 200, height: 100 }); + }); +}); diff --git a/packages/react-lightning-components/src/components/VirtualList/computeItemRect.ts b/packages/react-lightning-components/src/components/VirtualList/computeItemRect.ts new file mode 100644 index 00000000..f57b7621 --- /dev/null +++ b/packages/react-lightning-components/src/components/VirtualList/computeItemRect.ts @@ -0,0 +1,23 @@ +import type { ComputedLayout } from './LayoutManager'; +import type { ItemLayout } from './VirtualListTypes'; + +/** + * Translates a `LayoutManager` item-space layout into a scroll-space rect in + * the content container's coordinate system — the same mapping the rendered + * cells use (main axis shifted past the leading padding + header via + * `itemAreaOffset`, cross axis past the cross padding). Backs the + * `VirtualListRef.getLayout` imperative API. + */ +export function computeItemRect( + layout: ComputedLayout, + itemAreaOffset: number, + paddingCross: number, + horizontal: boolean | null | undefined, +): ItemLayout { + const main = itemAreaOffset + layout.offset; + const cross = paddingCross + layout.crossOffset; + + return horizontal + ? { x: main, y: cross, width: layout.size, height: layout.crossSize } + : { x: cross, y: main, width: layout.crossSize, height: layout.size }; +} diff --git a/packages/react-lightning-components/src/components/VirtualList/index.ts b/packages/react-lightning-components/src/components/VirtualList/index.ts index cb0e1d29..fa81e59c 100644 --- a/packages/react-lightning-components/src/components/VirtualList/index.ts +++ b/packages/react-lightning-components/src/components/VirtualList/index.ts @@ -1,6 +1,7 @@ export { VirtualList } from './VirtualList'; export type { ContentStyle, + ItemLayout, OverrideItemLayout, OverrideItemLayoutFn, ScrollEvent, diff --git a/packages/react-lightning-components/src/components/VirtualList/resolveChildSnapAlignment.spec.ts b/packages/react-lightning-components/src/components/VirtualList/resolveChildSnapAlignment.spec.ts new file mode 100644 index 00000000..4b86716d --- /dev/null +++ b/packages/react-lightning-components/src/components/VirtualList/resolveChildSnapAlignment.spec.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; + +import type { LightningElement } from '@plextv/react-lightning'; + +import { resolveChildSnapAlignment } from './resolveChildSnapAlignment'; + +function createMockElement( + props: Record, + children: LightningElement[] = [], +): LightningElement { + return { props, children } as unknown as LightningElement; +} + +describe('resolveChildSnapAlignment', () => { + it('returns the alignment carried by the starting element', () => { + const cell = createMockElement({ scrollSnapAlign: 'center' }); + + expect(resolveChildSnapAlignment(cell)).toBe('center'); + }); + + it('descends first children to the row root', () => { + const row = createMockElement({ scrollSnapAlign: 'center' }); + const flexRoot = createMockElement({}, [row]); + const cell = createMockElement({}, [flexRoot]); + + expect(resolveChildSnapAlignment(cell)).toBe('center'); + }); + + it('ignores rows that carry no alignment', () => { + const row = createMockElement({}); + const cell = createMockElement({}, [createMockElement({}, [row])]); + + expect(resolveChildSnapAlignment(cell)).toBeUndefined(); + }); + + it('ignores values that are not valid alignments', () => { + const row = createMockElement({ scrollSnapAlign: 'sideways' }); + const cell = createMockElement({}, [row]); + + expect(resolveChildSnapAlignment(cell)).toBeUndefined(); + }); + + it('stops descending past the depth cap', () => { + let deep = createMockElement({ scrollSnapAlign: 'center' }); + + for (let i = 0; i < 6; i++) { + deep = createMockElement({}, [deep]); + } + + expect(resolveChildSnapAlignment(deep)).toBeUndefined(); + }); +}); diff --git a/packages/react-lightning-components/src/components/VirtualList/resolveChildSnapAlignment.ts b/packages/react-lightning-components/src/components/VirtualList/resolveChildSnapAlignment.ts new file mode 100644 index 00000000..df345d5a --- /dev/null +++ b/packages/react-lightning-components/src/components/VirtualList/resolveChildSnapAlignment.ts @@ -0,0 +1,35 @@ +import type { LightningElement } from '@plextv/react-lightning'; + +type SnapAlignment = 'start' | 'center' | 'end'; + +const VALID_ALIGNMENTS: ReadonlySet = new Set(['start', 'center', 'end']); + +// The row root is a first-child descent away (cell FocusGroup -> FlexRoot -> +// row); the cap only bounds the walk on rows with deep single-child chains. +const MAX_DEPTH = 5; + +/** + * The focused row's own `scrollSnapAlign`, read from the cell's content. + * + * react-native-tvos lets each list row override the list-level snap alignment + * (`snapToAlignment="item"` defers entirely to the rows). The prop rides on + * the row's Pressable/View and passes through to the Lightning element. + * Focus events hand the list its direct child (the cell wrapper), so the row + * root is found by descending first children; separators render after the + * content, so the first child is always the content side. + */ +export function resolveChildSnapAlignment(cell: LightningElement): SnapAlignment | undefined { + let curr: LightningElement | null = cell; + + for (let depth = 0; curr && depth < MAX_DEPTH; depth++) { + const value = (curr.props as { scrollSnapAlign?: unknown }).scrollSnapAlign; + + if (typeof value === 'string' && VALID_ALIGNMENTS.has(value)) { + return value as SnapAlignment; + } + + curr = curr.children[0] ?? null; + } + + return undefined; +} diff --git a/packages/react-lightning-components/src/components/VirtualList/resolveCrossSize.spec.ts b/packages/react-lightning-components/src/components/VirtualList/resolveCrossSize.spec.ts new file mode 100644 index 00000000..2fbd3157 --- /dev/null +++ b/packages/react-lightning-components/src/components/VirtualList/resolveCrossSize.spec.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest'; + +import { DEFAULT_ITEM_SIZE } from './LayoutManager'; +import { resolveCrossSize } from './resolveCrossSize'; + +const base = { + horizontal: false, + explicitCross: undefined as number | undefined, + parentCross: undefined as number | undefined, + measuredOuterCross: 0, + maxContentCross: 0, + crossPadding: 0, +}; + +describe('resolveCrossSize', () => { + it('prefers an explicit cross size and marks it definite', () => { + const result = resolveCrossSize({ + ...base, + explicitCross: 400, + parentCross: 300, + }); + + expect(result).toEqual({ viewportCrossSize: 400, isDefinite: true }); + }); + + it('uses parent cell bounds for a vertical list and marks it definite', () => { + const result = resolveCrossSize({ + ...base, + parentCross: 320, + measuredOuterCross: 280, + }); + + expect(result).toEqual({ viewportCrossSize: 320, isDefinite: true }); + }); + + it('uses the measured outer size for a vertical list and marks it definite', () => { + const result = resolveCrossSize({ + ...base, + measuredOuterCross: 280, + maxContentCross: 120, + }); + + expect(result).toEqual({ viewportCrossSize: 280, isDefinite: true }); + }); + + it('ignores parent/measured cross for a horizontal list in favor of content', () => { + const result = resolveCrossSize({ + ...base, + horizontal: true, + parentCross: 600, + measuredOuterCross: 600, + maxContentCross: 180, + crossPadding: 10, + }); + + expect(result).toEqual({ viewportCrossSize: 190, isDefinite: false }); + }); + + it('ignores parent cross for a horizontal list and falls back to the default', () => { + // parentCross is the outer VL cell height (header + this list + siblings); + // deriving the horizontal cross from it ratchets unbounded. Fall through to + // the default and let content report the real size. + const result = resolveCrossSize({ + ...base, + horizontal: true, + parentCross: 600, + }); + + expect(result).toEqual({ + viewportCrossSize: DEFAULT_ITEM_SIZE, + isDefinite: false, + }); + }); + + it('ignores the measured outer size for a horizontal list and falls back to the default', () => { + const result = resolveCrossSize({ + ...base, + horizontal: true, + measuredOuterCross: 600, + }); + + expect(result).toEqual({ + viewportCrossSize: DEFAULT_ITEM_SIZE, + isDefinite: false, + }); + }); + + it('falls back to the default item size when nothing has measured', () => { + const result = resolveCrossSize({ ...base }); + + expect(result).toEqual({ + viewportCrossSize: DEFAULT_ITEM_SIZE, + isDefinite: false, + }); + }); + + it('treats a zero explicit cross as unset', () => { + const result = resolveCrossSize({ + ...base, + explicitCross: 0, + parentCross: 320, + }); + + expect(result).toEqual({ viewportCrossSize: 320, isDefinite: true }); + }); +}); diff --git a/packages/react-lightning-components/src/components/VirtualList/resolveCrossSize.ts b/packages/react-lightning-components/src/components/VirtualList/resolveCrossSize.ts new file mode 100644 index 00000000..a540aa8a --- /dev/null +++ b/packages/react-lightning-components/src/components/VirtualList/resolveCrossSize.ts @@ -0,0 +1,78 @@ +import { DEFAULT_ITEM_SIZE } from './LayoutManager'; + +export interface ResolveCrossSizeInput { + horizontal: boolean | null | undefined; + /** Cross-axis size from the VL's own style (`h` for horizontal, `w` for vertical). */ + explicitCross: number | undefined; + /** Cross-axis size of the parent VirtualList cell, when nested. */ + parentCross: number | undefined; + /** Self-measured cross-axis size of the VL's outer element. */ + measuredOuterCross: number; + /** Largest cross-axis content measurement reported by cells so far. */ + maxContentCross: number; + crossPadding: number; +} + +export interface ResolvedCrossSize { + viewportCrossSize: number; + /** + * True when the size came from an external source (explicit style, parent + * cell bounds, or the flex-allocated outer size) rather than from content + * measurement or the estimate. Cells may safely pin their cross axis to a + * definite size; pinning a content-derived one would freeze it before the + * content gets a chance to report its real size. + */ + isDefinite: boolean; +} + +/** + * Resolves the viewport cross-axis size for a VirtualList. + * + * Cross-axis priority differs by orientation. Vertical: parent/measured + * cross is reliable (parent flex allocates column width; content sits + * behind a FlexBoundary and can't feed back into it). Horizontal: + * parent/measured cross is the OUTER cell's full height (title + this VL + * + siblings) which is bigger than the cards themselves — prefer + * content-driven `maxContentCross` and only fall back when no content + * has measured yet. Without the asymmetry the cells oscillate as the + * outer cell's measured height churns during scroll/focus animations. + */ +export function resolveCrossSize({ + horizontal, + explicitCross, + parentCross, + measuredOuterCross, + maxContentCross, + crossPadding, +}: ResolveCrossSizeInput): ResolvedCrossSize { + if (explicitCross != null && explicitCross > 0) { + return { viewportCrossSize: explicitCross, isDefinite: true }; + } + + if (!horizontal && parentCross != null && parentCross > 0) { + return { viewportCrossSize: parentCross, isDefinite: true }; + } + + if (!horizontal && measuredOuterCross > 0) { + return { viewportCrossSize: measuredOuterCross, isDefinite: true }; + } + + if (maxContentCross > 0) { + return { + viewportCrossSize: maxContentCross + crossPadding, + isDefinite: false, + }; + } + + // Horizontal cross must not come from parent/self measurement: both equal the + // outer VL cell height (header + this list), so it ratchets unbounded. + if (!horizontal && parentCross != null && parentCross > 0) { + return { viewportCrossSize: parentCross, isDefinite: false }; + } + + if (!horizontal && measuredOuterCross > 0) { + return { viewportCrossSize: measuredOuterCross, isDefinite: false }; + } + + return { viewportCrossSize: DEFAULT_ITEM_SIZE, isDefinite: false }; +} diff --git a/packages/react-lightning-components/src/components/VirtualList/resolveFocusScrollTarget.spec.ts b/packages/react-lightning-components/src/components/VirtualList/resolveFocusScrollTarget.spec.ts new file mode 100644 index 00000000..82b4f8ad --- /dev/null +++ b/packages/react-lightning-components/src/components/VirtualList/resolveFocusScrollTarget.spec.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveFocusScrollTarget } from './resolveFocusScrollTarget'; + +// A start-aligned row centered by large symmetric padding, like the +// "Who's watching?" user picker: item 0 sits half a viewport in, so the +// focused item's target equals its index step and must not snap to an edge. +const centeredRow = { + viewportSize: 1920, + snapToAlignment: 'start' as const, + paddingStart: 848, + paddingEnd: 1696, + headerSize: 0, + footerSize: 0, + maxScroll: 848 + 5 * 233 + 1696 - 1920, +}; + +describe('resolveFocusScrollTarget', () => { + it('start-aligns the focused item by its padding', () => { + expect( + resolveFocusScrollTarget({ + ...centeredRow, + childOffset: 848 + 3 * 233, + childSize: 224, + }), + ).toBe(3 * 233); + }); + + it('does not snap a near-start centered target to 0 (no header to protect)', () => { + // Regression: with the old threshold (paddingStart + headerSize) this + // near-start target fell inside the centering padding and snapped to 0. + expect( + resolveFocusScrollTarget({ + ...centeredRow, + childOffset: 848 + 1 * 233, + childSize: 224, + }), + ).toBe(1 * 233); + }); + + it('does not snap a near-end centered target to maxScroll (no footer to protect)', () => { + const target = resolveFocusScrollTarget({ + ...centeredRow, + childOffset: 848 + 4 * 233, + childSize: 224, + }); + + expect(target).toBe(4 * 233); + expect(target).toBeLessThan(centeredRow.maxScroll); + }); + + it('snaps to 0 to keep a real header fully visible', () => { + expect( + resolveFocusScrollTarget({ + viewportSize: 1920, + snapToAlignment: 'start', + paddingStart: 48, + paddingEnd: 48, + headerSize: 120, + footerSize: 0, + maxScroll: 5000, + childOffset: 48 + 120, + childSize: 256, + }), + ).toBe(0); + }); + + it('snaps to maxScroll to keep a real footer fully visible', () => { + expect( + resolveFocusScrollTarget({ + viewportSize: 1920, + snapToAlignment: 'start', + paddingStart: 48, + paddingEnd: 48, + headerSize: 0, + footerSize: 120, + maxScroll: 5000, + childOffset: 5048, + childSize: 256, + }), + ).toBe(5000); + }); + + it('centers when asked', () => { + expect( + resolveFocusScrollTarget({ + viewportSize: 1920, + snapToAlignment: 'center', + paddingStart: 0, + paddingEnd: 0, + headerSize: 0, + footerSize: 0, + maxScroll: 5000, + childOffset: 1000, + childSize: 200, + }), + ).toBe(1000 + 100 - 960); + }); +}); diff --git a/packages/react-lightning-components/src/components/VirtualList/resolveFocusScrollTarget.ts b/packages/react-lightning-components/src/components/VirtualList/resolveFocusScrollTarget.ts new file mode 100644 index 00000000..dc38887c --- /dev/null +++ b/packages/react-lightning-components/src/components/VirtualList/resolveFocusScrollTarget.ts @@ -0,0 +1,60 @@ +export interface FocusScrollTargetParams { + /** Focused child's main-axis offset within the content container. */ + childOffset: number; + /** Focused child's main-axis size. */ + childSize: number; + viewportSize: number; + snapToAlignment: 'start' | 'center' | 'end'; + /** Main-axis start padding (scroll margin). */ + paddingStart: number; + /** Main-axis end padding (scroll margin). */ + paddingEnd: number; + /** Header main-axis size, excluding padding. */ + headerSize: number; + /** Footer main-axis size, excluding padding. */ + footerSize: number; + maxScroll: number; +} + +// Scroll offset that brings the focused child into the requested alignment. +// +// The edge snap keeps a real header/footer fully visible when the target lands +// inside it. It keys off the header/footer size, NOT the leading/trailing +// padding: a large centering padding (the switch-user row pads by ~half the +// viewport on each side) would otherwise pull every near-start target to 0 and +// every near-end target to maxScroll, so centering only worked in the middle. +export function resolveFocusScrollTarget({ + childOffset, + childSize, + viewportSize, + snapToAlignment, + paddingStart, + paddingEnd, + headerSize, + footerSize, + maxScroll, +}: FocusScrollTargetParams): number { + let target: number; + + switch (snapToAlignment) { + case 'center': + target = childOffset + childSize / 2 - viewportSize / 2; + break; + case 'end': + target = childOffset + childSize - viewportSize + paddingEnd; + break; + default: + target = childOffset - paddingStart; + break; + } + + if (target > 0 && target <= headerSize) { + return 0; + } + + if (target < maxScroll && target >= maxScroll - footerSize) { + return maxScroll; + } + + return target; +} diff --git a/packages/react-lightning-components/src/components/VirtualList/resolveRevealBoundary.spec.ts b/packages/react-lightning-components/src/components/VirtualList/resolveRevealBoundary.spec.ts new file mode 100644 index 00000000..68e0cadc --- /dev/null +++ b/packages/react-lightning-components/src/components/VirtualList/resolveRevealBoundary.spec.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveRevealBoundary } from './resolveRevealBoundary'; + +const never = () => Infinity; +const settled = () => 0; +const noneExempt = () => false; +const allExempt = () => true; + +describe('resolveRevealBoundary', () => { + it('reveals everything when every cell is exempt (fixed-size list)', () => { + const result = resolveRevealBoundary([0, 1, 2], allExempt, never); + + expect(result).toEqual({ revealThrough: 2, nextCheckMs: Infinity }); + }); + + it('reveals nothing while the first cell is unsettled', () => { + const result = resolveRevealBoundary([0, 1, 2], noneExempt, (i) => + i === 0 ? 50 : 0, + ); + + expect(result).toEqual({ revealThrough: -1, nextCheckMs: 50 }); + }); + + it('reveals the settled prefix and stops at the first unsettled cell', () => { + // 0,1 settled; 2 still growing; 3 would be settled but sits behind 2. + const result = resolveRevealBoundary([0, 1, 2, 3], noneExempt, (i) => + i === 2 ? 40 : 0, + ); + + expect(result).toEqual({ revealThrough: 1, nextCheckMs: 40 }); + }); + + it('withholds the unsettled cell itself so it never grows on screen', () => { + const result = resolveRevealBoundary([0, 1], noneExempt, (i) => + i === 1 ? 30 : 0, + ); + + expect(result.revealThrough).toBe(0); + }); + + it('reveals past exempt cells that sit before the block point', () => { + // index 1 exempt (fixed), 2 unsettled. + const result = resolveRevealBoundary( + [0, 1, 2, 3], + (i) => i === 1, + (i) => (i === 2 ? 25 : 0), + ); + + expect(result).toEqual({ revealThrough: 1, nextCheckMs: 25 }); + }); + + it('does not schedule a timer for a never-measured blocker', () => { + const result = resolveRevealBoundary([0, 1], noneExempt, never); + + expect(result).toEqual({ revealThrough: -1, nextCheckMs: Infinity }); + }); + + it('reveals all when the whole visible range has settled', () => { + const result = resolveRevealBoundary([4, 5, 6], noneExempt, settled); + + expect(result).toEqual({ revealThrough: 6, nextCheckMs: Infinity }); + }); +}); diff --git a/packages/react-lightning-components/src/components/VirtualList/resolveRevealBoundary.ts b/packages/react-lightning-components/src/components/VirtualList/resolveRevealBoundary.ts new file mode 100644 index 00000000..506bfb84 --- /dev/null +++ b/packages/react-lightning-components/src/components/VirtualList/resolveRevealBoundary.ts @@ -0,0 +1,49 @@ +export interface RevealBoundary { + /** + * Highest visible index allowed to paint; cells after it stay withheld. + * `-1` means nothing can paint yet (the first visible cell hasn't settled). + */ + revealThrough: number; + /** + * Smallest positive wait (ms) among cells blocking the boundary, for + * scheduling a re-check. `Infinity` when nothing is pending on a timer + * (either everything is revealed, or a blocker hasn't measured yet and will + * wake the list via its first size report). + */ + nextCheckMs: number; +} + +/** + * Walks the visible cells in order and finds how far the list may paint. A cell + * reveals once it and every earlier visible cell have settled; the first + * unsettled cell (and everything after it) stays withheld so it never grows on + * screen or shifts its neighbours. Fixed-size cells (`isExempt`) never block. + */ +export function resolveRevealBoundary( + order: readonly number[], + isExempt: (index: number) => boolean, + timeUntilSettled: (index: number) => number, +): RevealBoundary { + let revealThrough = -1; + + for (const index of order) { + if (isExempt(index)) { + revealThrough = index; + continue; + } + + const remaining = timeUntilSettled(index); + + if (remaining === 0) { + revealThrough = index; + continue; + } + + return { + revealThrough, + nextCheckMs: Number.isFinite(remaining) ? remaining : Infinity, + }; + } + + return { revealThrough, nextCheckMs: Infinity }; +} diff --git a/packages/react-lightning-components/src/components/VirtualList/resolveSectionSize.spec.ts b/packages/react-lightning-components/src/components/VirtualList/resolveSectionSize.spec.ts new file mode 100644 index 00000000..59941bc6 --- /dev/null +++ b/packages/react-lightning-components/src/components/VirtualList/resolveSectionSize.spec.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveSectionSize } from './resolveSectionSize'; + +describe('resolveSectionSize', () => { + it('reserves nothing when there is no section component', () => { + expect(resolveSectionSize(false, 120, 40)).toBe(0); + }); + + it('uses the measured size once the section has laid out', () => { + expect(resolveSectionSize(true, 120, 0)).toBe(120); + }); + + it('prefers the measured size over the caller estimate', () => { + expect(resolveSectionSize(true, 120, 40)).toBe(120); + }); + + it('falls back to the caller estimate before measurement', () => { + expect(resolveSectionSize(true, 0, 40)).toBe(40); + }); + + it('is zero when present but neither measured nor estimated', () => { + expect(resolveSectionSize(true, 0, 0)).toBe(0); + }); +}); diff --git a/packages/react-lightning-components/src/components/VirtualList/resolveSectionSize.ts b/packages/react-lightning-components/src/components/VirtualList/resolveSectionSize.ts new file mode 100644 index 00000000..26dce8ff --- /dev/null +++ b/packages/react-lightning-components/src/components/VirtualList/resolveSectionSize.ts @@ -0,0 +1,14 @@ +// Header/footer main-axis size. Measured content wins over the caller's +// estimate (`listHeaderSize`/`listFooterSize`), which now only bridges the +// gap before the section has laid out. No component reserves no space. +export function resolveSectionSize( + hasComponent: boolean, + measuredSize: number, + fallbackSize: number, +): number { + if (!hasComponent) { + return 0; + } + + return measuredSize > 0 ? measuredSize : fallbackSize; +} diff --git a/packages/react-lightning-components/src/components/VirtualList/resolveVisibleMainSpan.spec.ts b/packages/react-lightning-components/src/components/VirtualList/resolveVisibleMainSpan.spec.ts new file mode 100644 index 00000000..f8d86518 --- /dev/null +++ b/packages/react-lightning-components/src/components/VirtualList/resolveVisibleMainSpan.spec.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; + +import { capSelfMeasuredViewport } from './capSelfMeasuredViewport'; +import { resolveFocusScrollTarget } from './resolveFocusScrollTarget'; +import { resolveVisibleMainSpan } from './resolveVisibleMainSpan'; + +describe('resolveVisibleMainSpan', () => { + it('measures a horizontal list from its left edge to the stage right edge', () => { + expect(resolveVisibleMainSpan(true, 1920, 1080, 0, 300)).toBe(1920); + }); + + it('measures a vertical list from its top edge to the stage bottom edge', () => { + expect(resolveVisibleMainSpan(false, 1920, 1080, 200, 300)).toBe(780); + }); + + it('grows the span for a full-bleed list that starts off the left edge', () => { + // marginLeft pulls the list start negative; it overflows nothing on screen. + expect(resolveVisibleMainSpan(true, 1920, 1080, -848, 0)).toBe(2768); + }); + + // The centered switch-user row: its -848 overflow margin inflates the + // self-measured width to 2768, but only 1920 is on screen. Capping to the + // visible span is what lets center snap land the focused tile at 960. + it('caps the inflated switch-user viewport and centers the focused tile', () => { + const rawMeasured = 2768; + const span = resolveVisibleMainSpan(true, 1920, 1080, 0, 0); + const viewportSize = capSelfMeasuredViewport(rawMeasured, span); + + expect(viewportSize).toBe(1920); + + const target = resolveFocusScrollTarget({ + childOffset: 1144, + childSize: 224, + viewportSize, + snapToAlignment: 'center', + paddingStart: 848, + paddingEnd: 1696, + headerSize: 0, + footerSize: 0, + maxScroll: 4248 - viewportSize, + }); + + // Centers the tile in the visible 1920: 1144 + 112 - 960. + expect(target).toBe(296); + // On-screen tile center = childOffset - target + childSize / 2 = 960. + expect(1144 - target + 224 / 2).toBe(960); + }); + + it('does not center against the off-screen width when left uncapped', () => { + // Regression guard: with the raw 2768 viewport the center target goes + // negative and clamps to 0, so the tile never leaves its start position. + const target = resolveFocusScrollTarget({ + childOffset: 1144, + childSize: 224, + viewportSize: 2768, + snapToAlignment: 'center', + paddingStart: 848, + paddingEnd: 1696, + headerSize: 0, + footerSize: 0, + maxScroll: 4248 - 2768, + }); + + expect(target).toBeLessThan(0); + }); +}); diff --git a/packages/react-lightning-components/src/components/VirtualList/resolveVisibleMainSpan.ts b/packages/react-lightning-components/src/components/VirtualList/resolveVisibleMainSpan.ts new file mode 100644 index 00000000..6029bf89 --- /dev/null +++ b/packages/react-lightning-components/src/components/VirtualList/resolveVisibleMainSpan.ts @@ -0,0 +1,12 @@ +// Main-axis distance from the list's start edge to the far stage edge. Caps a +// self-measured viewport to what's on screen (horizontal reads width/x, +// vertical height/y); negative list starts (full-bleed overflow) grow it. +export function resolveVisibleMainSpan( + horizontal: boolean | null | undefined, + rootWidth: number, + rootHeight: number, + listX: number, + listY: number, +): number { + return horizontal ? rootWidth - listX : rootHeight - listY; +} diff --git a/packages/react-lightning-components/src/components/VirtualList/scrollSpring.spec.ts b/packages/react-lightning-components/src/components/VirtualList/scrollSpring.spec.ts new file mode 100644 index 00000000..06038cac --- /dev/null +++ b/packages/react-lightning-components/src/components/VirtualList/scrollSpring.spec.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; + +import { createCriticalSpring } from './scrollSpring'; + +// UIKit mapping for a 0.6s nominal spring (2*pi/duration). +const OMEGA = (2 * Math.PI) / 600; + +describe('createCriticalSpring', () => { + it('starts at the initial displacement and velocity', () => { + const spring = createCriticalSpring(-800, 3, OMEGA); + + expect(spring.position(0)).toBe(-800); + expect(spring.velocity(0)).toBeCloseTo(3, 5); + + // Numeric derivative agrees with the analytic velocity. + const h = 0.01; + const numeric = (spring.position(h) - spring.position(0)) / h; + + expect(numeric).toBeCloseTo(spring.velocity(0), 2); + }); + + it('leaves under ~1.5% of the distance at the UIKit cutoff', () => { + const spring = createCriticalSpring(-800, 0, OMEGA); + + expect(Math.abs(spring.position(600))).toBeLessThan(800 * 0.015); + expect(Math.abs(spring.position(600))).toBeGreaterThan(0); + }); + + it('decelerates through the tail instead of stopping hard', () => { + const spring = createCriticalSpring(-800, 0, OMEGA); + const speedAt = (t: number) => Math.abs(spring.velocity(t)); + + // Velocity ramps up, peaks, then decays smoothly. + expect(speedAt(150)).toBeGreaterThan(speedAt(300)); + expect(speedAt(300)).toBeGreaterThan(speedAt(450)); + }); + + it('crosses the target at most once with a large carried velocity', () => { + const spring = createCriticalSpring(-100, 10, OMEGA); + let crossings = 0; + let prevSign = Math.sign(spring.position(0)); + + for (let t = 1; t <= 1000; t++) { + const sign = Math.sign(spring.position(t)); + + if (sign !== 0 && sign !== prevSign) { + crossings += 1; + prevSign = sign; + } + } + + expect(crossings).toBeLessThanOrEqual(1); + expect(Math.abs(spring.position(2000))).toBeLessThan(1); + }); +}); diff --git a/packages/react-lightning-components/src/components/VirtualList/scrollSpring.ts b/packages/react-lightning-components/src/components/VirtualList/scrollSpring.ts new file mode 100644 index 00000000..0383f227 --- /dev/null +++ b/packages/react-lightning-components/src/components/VirtualList/scrollSpring.ts @@ -0,0 +1,27 @@ +export interface CriticalSpringMotion { + /** Displacement from the target at time t (same sign as the initial displacement). */ + position: (t: number) => number; + /** Velocity at time t, in units/ms. */ + velocity: (t: number) => number; +} + +/** + * Critically damped spring, the motion behind UIKit's focus-driven scroll on + * tvOS. `displacement` is start minus target, `velocity` the carried speed + * (units/ms), `omega` the natural frequency (1/ms, higher settles faster). + * Carried velocity can make it cross the target once and glide back; it + * never oscillates. + */ +export function createCriticalSpring( + displacement: number, + velocity: number, + omega: number, +): CriticalSpringMotion { + const b = velocity + omega * displacement; + + return { + position: (t: number): number => (displacement + b * t) * Math.exp(-omega * t), + velocity: (t: number): number => + (b - omega * (displacement + b * t)) * Math.exp(-omega * t), + }; +} diff --git a/packages/react-lightning-components/src/components/VirtualList/useScrollHandler.ts b/packages/react-lightning-components/src/components/VirtualList/useScrollHandler.ts index b8349bc0..d23c4a9f 100644 --- a/packages/react-lightning-components/src/components/VirtualList/useScrollHandler.ts +++ b/packages/react-lightning-components/src/components/VirtualList/useScrollHandler.ts @@ -5,6 +5,36 @@ import type { LightningElement } from '@plextv/react-lightning'; import type { LayoutManager } from './LayoutManager'; import type { ScrollEvent } from './VirtualListTypes'; +import { createCriticalSpring } from './scrollSpring'; +import { resolveChildSnapAlignment } from './resolveChildSnapAlignment'; +import { resolveFocusScrollTarget } from './resolveFocusScrollTarget'; + +// Lightning Magic Remote / mouse support (in the host app) installs this hook +// while a pointer is driving focus. Read it off globalThis so this subtree stays +// free of app imports; undefined (a no-op) on every platform that never loads it. +const isPointerFocusScrollSuppressed = (): boolean => { + const fn = ( + globalThis as { __plexShouldSuppressPointerFocusScroll?: () => boolean } + ).__plexShouldSuppressPointerFocusScroll; + + return typeof fn === 'function' && fn(); +}; + +// Lightning mirror of the tvOS scroll spring (app sets dampingRatio 1, +// initialSpringVelocity 0.25 via initializeScrollTransition). Frequency is fit +// to Apple TV sim screen recordings, not the config's nominal 0.6s: UIKit's +// damped spring settles ~1.5x faster than that nominal implies. Measured +// effective omega ~0.0148/ms vertical, ~0.0160 horizontal (critical-spring fit +// RMS < 0.012); 410ms nominal = 2pi/omega sits between. +const SPRING_OMEGA = (2 * Math.PI) / 410; +const SPRING_INITIAL_VELOCITY = 0.25; +// Let the spring settle naturally (like UIKit) and snap only once it's within +// half a pixel of the target: a fixed-time cutoff leaves ~1% of the distance +// and snapping that gap reads as a small bounce at the end. Cap the tail so a +// stalled frame clock still terminates. +const SPRING_SETTLE_PX = 0.5; +const SPRING_MAX_DURATION_MS = 1200; + export interface UseScrollHandlerOptions { layoutManager: LayoutManager; horizontal: boolean | null; @@ -78,11 +108,19 @@ export function useScrollHandler(options: UseScrollHandlerOptions): UseScrollHan const scrollOffsetRef = useRef(initialScrollOffset); const endReachedRef = useRef(false); const animationIdRef = useRef(0); - // True from the moment an animated scroll begins until the final - // `stopped` event fires (or `resetScroll` cancels). Guards both ends - // of the start/end notification so chained animations only fire one - // start and one end overall. + // True from the moment an animated scroll begins until the animation loop + // completes (or `resetScroll` cancels). Guards both ends of the start/end + // notification so chained animations only fire one start and one end overall. const isAnimatingRef = useRef(false); + // rAF id of the in-flight scroll animation loop; non-zero while animating. + const scrollRafRef = useRef(0); + // Live velocity of the in-flight animation (px/ms, signed); feeds the + // momentum curve when a scroll is retargeted mid-flight. + const scrollVelocityRef = useRef(0); + // Target of the in-flight animated scroll, so a duplicate request for the + // same target doesn't restart the spring (and re-inject velocity). + const animTargetRef = useRef(null); + const lastTickRef = useRef<{ pos: number; time: number } | null>(null); const [committedScrollOffset, setCommittedScrollOffset] = useState(initialScrollOffset); // Pin restored scroll offset to node.x/y on mount so first paint matches @@ -100,12 +138,34 @@ export function useScrollHandler(options: UseScrollHandlerOptions): UseScrollHan } }, []); + // oxlint-disable-next-line react-hooks/exhaustive-deps -- unmount-only cleanup + useEffect(() => { + return () => { + if (scrollRafRef.current) { + cancelAnimationFrame(scrollRafRef.current); + } + }; + }, []); + const maxScroll = Math.max(0, totalContentSize - viewportSize); function clamp(value: number): number { return Math.max(0, Math.min(value, maxScroll)); } + function makeScrollEvent(offset: number): ScrollEvent { + return { + contentInset: { top: 0, left: 0, bottom: 0, right: 0 }, + contentOffset: horizontal ? { x: offset, y: 0 } : { x: 0, y: offset }, + contentSize: horizontal + ? { width: totalContentSize, height: totalCrossSize } + : { width: totalCrossSize, height: totalContentSize }, + layoutMeasurement: horizontal + ? { width: viewportSize, height: viewportCrossSize } + : { width: viewportCrossSize, height: viewportSize }, + }; + } + function applyPosition(offset: number, animated: boolean): void { const el = contentRef.current; @@ -123,28 +183,79 @@ export function useScrollHandler(options: UseScrollHandlerOptions): UseScrollHan onAnimationStart?.(); } - const anim = horizontal - ? el.node.animate({ x: value }, { duration: animationDuration, easing: 'ease-out' }) - : el.node.animate({ y: value }, { duration: animationDuration, easing: 'ease-out' }); + // Self-driven animation instead of node.animate: each frame emits + // onScroll first and then moves the node, so scroll-linked styles land + // in the same painted frame (sampling the node from a separate loop + // trailed the rows by a frame). Native lists emit scroll events + // throughout an animated scroll; scroll-linked effects rely on that. + const from = -(horizontal ? el.node.x : el.node.y); + const distance = offset - from; + const startTime = performance.now(); + + animTargetRef.current = offset; + + // 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 spring = createCriticalSpring(-distance, v0, SPRING_OMEGA); + + lastTickRef.current = { pos: from, time: startTime }; - anim.once('stopped', () => { - if (animationIdRef.current !== thisAnimId) { + const tick = (now: number): void => { + const target = contentRef.current; + + if (animationIdRef.current !== thisAnimId || !target) { return; } - // Pin the position so reconciliation doesn't reset it + const t = now - startTime; + const pos = spring.position(t); + const done = + (Math.abs(pos) < SPRING_SETTLE_PX && + Math.abs(spring.velocity(t)) < 0.01) || + t >= SPRING_MAX_DURATION_MS; + const current = done ? offset : offset + pos; + + const last = lastTickRef.current; + + if (last && now > last.time) { + scrollVelocityRef.current = (current - last.pos) / (now - last.time); + } + + lastTickRef.current = { pos: current, time: now }; + + onScroll?.(makeScrollEvent(clamp(current))); + if (horizontal) { - el.node.x = value; + target.node.x = -current; + } else { + target.node.y = -current; + } + + if (!done) { + scrollRafRef.current = requestAnimationFrame(tick); } else { - el.node.y = value; + scrollRafRef.current = 0; + scrollVelocityRef.current = 0; + animTargetRef.current = null; + isAnimatingRef.current = false; + onAnimationEnd?.(); } + }; - isAnimatingRef.current = false; - onAnimationEnd?.(); - }); + // Retargeting mid-flight restarts the curve from the live position; the + // stale loop is cancelled so only one drives the node. + if (scrollRafRef.current) { + cancelAnimationFrame(scrollRafRef.current); + } - anim.start(); + scrollRafRef.current = requestAnimationFrame(tick); } else { + scrollVelocityRef.current = 0; + if (horizontal) { el.node.x = value; } else { @@ -156,6 +267,19 @@ export function useScrollHandler(options: UseScrollHandlerOptions): UseScrollHan function scrollToOffset(offset: number, animated = true): void { const clamped = clamp(offset); + // A focus move fires two scroll requests on Lightning (VirtualList's own + // focus-follow and the app's ScrollIntoViewHelper). Ignore the duplicate + // so it doesn't restart the spring and re-inject velocity, which shows up + // as a small bounce at the end of the settle. + if ( + animated && + isAnimatingRef.current && + animTargetRef.current !== null && + Math.abs(clamped - animTargetRef.current) < 1 + ) { + return; + } + scrollOffsetRef.current = clamped; applyPosition(clamped, animated); @@ -176,16 +300,11 @@ export function useScrollHandler(options: UseScrollHandlerOptions): UseScrollHan } } - onScroll?.({ - contentInset: { top: 0, left: 0, bottom: 0, right: 0 }, - contentOffset: horizontal ? { x: clamped, y: 0 } : { x: 0, y: clamped }, - contentSize: horizontal - ? { width: totalContentSize, height: totalCrossSize } - : { width: totalCrossSize, height: totalContentSize }, - layoutMeasurement: horizontal - ? { width: viewportSize, height: viewportCrossSize } - : { width: viewportCrossSize, height: viewportSize }, - }); + // Animated scrolls stream onScroll from the emit loop instead; emitting + // the target immediately would defeat scroll-linked animations. + if (!(animated && animationDuration > 0 && contentRef.current)) { + onScroll?.(makeScrollEvent(clamped)); + } } function scrollToIndex(index: number, animated = true, viewPosition = 0, viewOffset = 0): void { @@ -206,6 +325,12 @@ export function useScrollHandler(options: UseScrollHandlerOptions): UseScrollHan } function handleChildFocused(child: LightningElement): void { + // Pointer hover moves focus; don't scroll to follow it or the row slides out + // from under a stationary cursor and the next click misses. + if (isPointerFocusScrollSuppressed()) { + return; + } + const el = contentRef.current; if (!el) { @@ -216,28 +341,23 @@ export function useScrollHandler(options: UseScrollHandlerOptions): UseScrollHan const childOffset = horizontal ? pos.x : pos.y; const childSize = horizontal ? child.node.w : child.node.h; - let target: number; - - switch (snapToAlignment) { - case 'center': - target = childOffset + childSize / 2 - viewportSize / 2; - break; - case 'end': - target = childOffset + childSize - viewportSize + paddingEnd; - break; - default: - target = childOffset - paddingStart; - break; - } - - // Snap to edges to keep header/footer visible when near them - const footerAreaSize = totalContentSize - itemAreaOffset - layoutManager.totalSize; - - if (target > 0 && target <= itemAreaOffset) { - target = 0; - } else if (target < maxScroll && target >= maxScroll - footerAreaSize) { - target = maxScroll; - } + const headerSize = itemAreaOffset - paddingStart; + const footerSize = + totalContentSize - itemAreaOffset - layoutManager.totalSize - paddingEnd; + + const target = resolveFocusScrollTarget({ + childOffset, + childSize, + viewportSize, + // A row's own scrollSnapAlign wins over the list-level alignment, + // matching react-native-tvos (snapToAlignment="item" defers to rows). + snapToAlignment: resolveChildSnapAlignment(child) ?? snapToAlignment, + paddingStart, + paddingEnd, + headerSize, + footerSize, + maxScroll, + }); scrollToOffset(target, true); } @@ -249,6 +369,14 @@ export function useScrollHandler(options: UseScrollHandlerOptions): UseScrollHan // Cancel any in-flight scroll animation so it doesn't snap back. animationIdRef.current++; + if (scrollRafRef.current) { + cancelAnimationFrame(scrollRafRef.current); + scrollRafRef.current = 0; + } + + scrollVelocityRef.current = 0; + animTargetRef.current = null; + if (isAnimatingRef.current) { isAnimatingRef.current = false; onAnimationEnd?.(); diff --git a/packages/react-lightning-components/src/exports/lists/VirtualList.tsx b/packages/react-lightning-components/src/exports/lists/VirtualList.tsx index 6a065460..6cf79bc6 100644 --- a/packages/react-lightning-components/src/exports/lists/VirtualList.tsx +++ b/packages/react-lightning-components/src/exports/lists/VirtualList.tsx @@ -1,5 +1,6 @@ export type { ContentStyle, + ItemLayout, OverrideItemLayout, OverrideItemLayoutFn, ScrollEvent, diff --git a/packages/react-lightning/src/element/FlattenedRendererNode.ts b/packages/react-lightning/src/element/FlattenedRendererNode.ts new file mode 100644 index 00000000..ec4ae140 --- /dev/null +++ b/packages/react-lightning/src/element/FlattenedRendererNode.ts @@ -0,0 +1,112 @@ +import type { RendererNode } from '../types'; +import type { LightningElement } from '../types/Element'; + +// Ids count down so a flattened placeholder is never mistaken for a renderer +// node (renderer ids start at 1). +let flattenedIdCounter = 0; + +type NoopAnimationController = { + start(): NoopAnimationController; + stop(): NoopAnimationController; + pause(): NoopAnimationController; + restore(): NoopAnimationController; + once(): NoopAnimationController; + on(): NoopAnimationController; + off(): NoopAnimationController; + waitUntilStopped(): Promise; + state: string; +}; + +const noopAnimationController: NoopAnimationController = { + start: () => noopAnimationController, + stop: () => noopAnimationController, + pause: () => noopAnimationController, + restore: () => noopAnimationController, + once: () => noopAnimationController, + on: () => noopAnimationController, + off: () => noopAnimationController, + waitUntilStopped: () => Promise.resolve(), + state: 'stopped', +}; + +/** Element hook a placeholder calls when its position is written directly. */ +export interface FlattenedNodeOwner { + onFlattenedAxisWrite(axis: 'x' | 'y', value: number): void; +} + +/** + * Placeholder standing in for a renderer node on a flattened (layout-only) + * element. Stores the handful of props the element layer reads back (position, + * size, alpha) and no-ops the renderer surface. Descendant elements with real + * nodes attach to the nearest non-flattened ancestor instead. + */ +export class FlattenedRendererNode { + public readonly isFlattenedNode = true; + public id: number = --flattenedIdCounter; + private _x = 0; + private _y = 0; + public w = 0; + public h = 0; + public alpha = 1; + public parent: unknown = null; + public shader: unknown = null; + public clipRadius = 0; + // Assigned by the element constructor, mirrored on materialize. + public __reactFiber: unknown = null; + public __reactNode: unknown = null; + // The owning element. A direct node.x/node.y write (a scroll handler moving + // the content node straight through node.x, bypassing setProps) has to fold + // through to the hoisted children, which only the element can do. + public owner: FlattenedNodeOwner | null = null; + + public get x(): number { + return this._x; + } + + public set x(value: number) { + if (this._x === value) { + return; + } + + this._x = value; + this.owner?.onFlattenedAxisWrite('x', value); + } + + public get y(): number { + return this._y; + } + + public set y(value: number) { + if (this._y === value) { + return; + } + + this._y = value; + this.owner?.onFlattenedAxisWrite('y', value); + } + + public constructor(props: Record) { + for (const key in props) { + if (key === 'data' || key === 'parent') { + continue; + } + + (this as Record)[key] = props[key]; + } + } + + public on(): void {} + public off(): void {} + public once(): void {} + public emit(): void {} + public animate(): typeof noopAnimationController { + return noopAnimationController; + } + public destroy(): void {} +} + +export function createFlattenedNode( + props: Record, +): RendererNode { + return new FlattenedRendererNode(props) as unknown as RendererNode; +} diff --git a/packages/react-lightning/src/element/LightningTextElement.ts b/packages/react-lightning/src/element/LightningTextElement.ts index cd70289e..93edee3c 100644 --- a/packages/react-lightning/src/element/LightningTextElement.ts +++ b/packages/react-lightning/src/element/LightningTextElement.ts @@ -1,6 +1,7 @@ import type { INodeProps } from '@lightningjs/renderer'; import { + type LightningElement, LightningElementType, type LightningTextElementProps, type LightningTextElementStyle, @@ -23,12 +24,78 @@ export class LightningTextElement extends LightningViewElement< return true; } + // Set once this element renders its text from child fragments rather than + // from its own `text` prop (see `shouldSetTextContent`). Once true we always + // derive `node.text` from the children so removing them clears it. + private _aggregatesChildText = false; + + // Last text we emitted a change for. Tracked separately from `node.text` + // because the base `_doUpdate` writes `node.text` directly (bypassing this + // setter), so comparing against `node.text` would miss real changes. + private _lastEmittedText: string | undefined; + public get text(): string { return this.node.text; } public set text(v) { this.node.text = v; + + // Text content can change without a `setProps`/`propsChanged` cycle — via + // `commitTextUpdate` (recycled nodes) or `recomputeChildText`. Emit a + // dedicated signal so consumers (e.g. the flexbox text-measure) re-measure + // on every value change, not just prop changes. + if (v !== this._lastEmittedText) { + this._lastEmittedText = v; + this.emit('textChanged', this); + } + } + + /** + * Children that resolve to plain text (e.g. the string a `` + * rendered to) are appended as their own text instances rather than handed to + * us as a `text` prop. We keep them in the reconciler's child list for + * ordering/cleanup but fold their text into this node and detach them from + * the render tree so only this element draws. + */ + public override insertChild( + child: LightningElement, + beforeChild?: LightningElement | null, + ): void { + super.insertChild(child, beforeChild); + + if (child.isTextElement) { + // Keep it out of the visual tree; its text lives in our node instead. + child.node.parent = null; + this._aggregatesChildText = true; + this.recomputeChildText(); + } + } + + public override removeChild(child: LightningElement): void { + super.removeChild(child); + + if (this._aggregatesChildText) { + this.recomputeChildText(); + } + } + + /** + * Recompute `node.text` from the ordered text of child text fragments. + * Called when fragments are added/removed and when one's text updates. + */ + public recomputeChildText(): void { + let text = ''; + + for (let i = 0; i < this.children.length; i++) { + const child = this.children[i]; + + if (child?.isTextElement) { + text += (child as LightningTextElement).text; + } + } + + this.text = text; } public override _toLightningNodeProps( diff --git a/packages/react-lightning/src/element/LightningViewElement.flatten.spec.ts b/packages/react-lightning/src/element/LightningViewElement.flatten.spec.ts new file mode 100644 index 00000000..64a126d7 --- /dev/null +++ b/packages/react-lightning/src/element/LightningViewElement.flatten.spec.ts @@ -0,0 +1,363 @@ +import type { RendererMain } from '@lightningjs/renderer'; +import type { Fiber } from 'react-reconciler'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { LightningViewElementProps, LightningViewElementStyle } from '../types'; +import { LightningViewElement } from './LightningViewElement'; + +function createMockNode(props: Record = {}) { + return { + x: 0, + y: 0, + w: 0, + h: 0, + alpha: 1, + color: 0, + clipRadius: 0, + shader: { props: {} }, + parent: null, + on() {}, + off() {}, + animate() { + let stopped: ((controller: unknown) => void) | null = null; + const controller = { + once(event: string, cb: (controller: unknown) => void) { + if (event === 'stopped') { + stopped = cb; + } + return controller; + }, + start() { + stopped?.(controller); + return controller; + }, + }; + return controller; + }, + destroy() {}, + ...props, + }; +} + +const destroyNode = vi.fn(); + +const renderer = { + createNode: (props: Record) => createMockNode(props), + createTextNode: (props: Record) => createMockNode(props), + createShader: () => ({ props: {} }), + createTexture: () => ({}), + destroyNode, +} as unknown as RendererMain; + +function createElement( + style: Partial, + extraProps: Record = {}, +) { + const props = { + style, + ...extraProps, + } as LightningViewElementProps; + + return new LightningViewElement(props, renderer, [], {} as Fiber); +} + +// setProps stages the update and flushes on a microtask. +const flush = () => Promise.resolve(); + +describe('flattenLayoutViews', () => { + beforeEach(() => { + LightningViewElement.flattenLayoutViewsEnabled = true; + destroyNode.mockClear(); + }); + + afterEach(() => { + LightningViewElement.flattenLayoutViewsEnabled = false; + }); + + it('creates a real node while the option is off (default)', () => { + LightningViewElement.flattenLayoutViewsEnabled = false; + + const el = createElement({ w: 100, h: 50 }); + + expect(el.isFlattened).toBe(false); + }); + + it('flattens a layout-only view', () => { + const el = createElement({ w: 100, h: 50 }); + + expect(el.isFlattened).toBe(true); + expect((el.node as unknown as { isFlattenedNode?: boolean }).isFlattenedNode).toBe(true); + expect(el.node.w).toBe(100); + expect(el.node.h).toBe(50); + }); + + it('does not flatten a view with a visual prop', () => { + const el = createElement({ w: 100, h: 50, color: 0xff0000ff }); + + expect(el.isFlattened).toBe(false); + }); + + it('flattens despite inert element props (handlers, testID)', () => { + const el = createElement( + { w: 100, h: 50 }, + { + onLayout: () => {}, + onFocus: () => {}, + testID: 'wrapper', + focusable: true, + }, + ); + + expect(el.isFlattened).toBe(true); + }); + + it('flattens neutral visual values (color 0, alpha 1, scale 1)', () => { + const el = createElement({ w: 100, h: 50, color: 0, alpha: 1, scale: 1 }); + + expect(el.isFlattened).toBe(true); + }); + + it('materialized nodes are stamped transparent, not renderer-default white', () => { + const el = createElement({ w: 100, h: 50 }); + + el.setNodeProp('alpha', 0.5, false); + + expect(el.isFlattened).toBe(false); + expect(el.node.color).toBe(0); + }); + + it('does not flatten a view with a transition', () => { + const el = createElement({ w: 100, h: 50 }, { transition: { x: { duration: 100 } } }); + + expect(el.isFlattened).toBe(false); + }); + + it('parents a real child through a flattened chain to the nearest real node', () => { + const root = createElement({ w: 1920, h: 1080, color: 0x000000ff }); + const wrapperA = createElement({ w: 500, h: 500 }); + const wrapperB = createElement({ w: 400, h: 400 }); + const leaf = createElement({ w: 100, h: 100, color: 0xffffffff }); + + root.insertChild(wrapperA); + wrapperA.insertChild(wrapperB); + wrapperB.insertChild(leaf); + + expect(wrapperA.isFlattened).toBe(true); + expect(wrapperB.isFlattened).toBe(true); + expect(leaf.isFlattened).toBe(false); + expect(leaf.node.parent).toBe(root.node); + }); + + it('folds flattened ancestors offsets into descendant node positions', () => { + const root = createElement({ w: 1920, h: 1080, color: 0x000000ff }); + const wrapperA = createElement({ w: 500, h: 500 }); + const wrapperB = createElement({ w: 400, h: 400 }); + const leaf = createElement({ w: 100, h: 100, color: 0xffffffff }); + + root.insertChild(wrapperA); + wrapperA.insertChild(wrapperB); + wrapperB.insertChild(leaf); + + wrapperA.setNodeProp('x', 10, false); + wrapperA.setNodeProp('y', 20, false); + wrapperB.setNodeProp('x', 100, false); + wrapperB.setNodeProp('y', 200, false); + leaf.setNodeProp('x', 5, false); + leaf.setNodeProp('y', 6, false); + + expect(leaf.node.x).toBe(115); + expect(leaf.node.y).toBe(226); + // The phantom keeps parent-relative coordinates. + expect(wrapperB.node.x).toBe(100); + }); + + it('re-derives descendant positions when a flattened ancestor moves', () => { + const root = createElement({ w: 1920, h: 1080, color: 0x000000ff }); + const wrapper = createElement({ w: 500, h: 500 }); + const leaf = createElement({ w: 100, h: 100, color: 0xffffffff }); + + root.insertChild(wrapper); + wrapper.insertChild(leaf); + + leaf.setNodeProp('x', 5, false); + wrapper.setNodeProp('x', 50, false); + + expect(leaf.node.x).toBe(55); + + wrapper.setNodeProp('x', 80, false); + + expect(leaf.node.x).toBe(85); + }); + + it('materializes on a visual style write and relinks children', async () => { + const root = createElement({ w: 1920, h: 1080, color: 0x000000ff }); + const wrapper = createElement({ w: 500, h: 500 }); + const leaf = createElement({ w: 100, h: 100, color: 0xffffffff }); + + root.insertChild(wrapper); + wrapper.insertChild(leaf); + + wrapper.setNodeProp('x', 50, false); + leaf.setNodeProp('x', 5, false); + + expect(leaf.node.x).toBe(55); + + wrapper.setProps({ style: { color: 0x123456ff } } as never); + await flush(); + + expect(wrapper.isFlattened).toBe(false); + expect((wrapper.node as unknown as { isFlattenedNode?: boolean }).isFlattenedNode).toBeUndefined(); + // The real node lands at the accumulated position... + expect(wrapper.node.x).toBe(50); + // ...and the child rebases to be relative to it. + expect(leaf.node.parent).toBe(wrapper.node); + expect(leaf.node.x).toBe(5); + }); + + it('materializes via setNodeProp with a non-layout key', () => { + const el = createElement({ w: 100, h: 50 }); + + expect(el.isFlattened).toBe(true); + + el.setNodeProp('alpha', 0.5, false); + + expect(el.isFlattened).toBe(false); + expect(el.node.alpha).toBe(0.5); + }); + + it('getRelativePosition does not double-count flattened ancestors', () => { + const root = createElement({ w: 1920, h: 1080, color: 0x000000ff }); + const wrapper = createElement({ w: 500, h: 500 }); + const leaf = createElement({ w: 100, h: 100, color: 0xffffffff }); + + root.insertChild(wrapper); + wrapper.insertChild(leaf); + + wrapper.setNodeProp('x', 50, false); + leaf.setNodeProp('x', 5, false); + + expect(leaf.getRelativePosition(root).x).toBe(55); + // Relative to the flattened wrapper itself: just the leaf's own offset. + expect(leaf.getRelativePosition(wrapper).x).toBe(5); + }); + + it('reparenting a flattened subtree relinks the real frontier', () => { + const rootA = createElement({ w: 1920, h: 1080, color: 0x000000ff }); + const rootB = createElement({ w: 1920, h: 1080, color: 0x111111ff }); + const wrapper = createElement({ w: 500, h: 500 }); + const leaf = createElement({ w: 100, h: 100, color: 0xffffffff }); + + rootA.insertChild(wrapper); + wrapper.insertChild(leaf); + wrapper.setNodeProp('x', 50, false); + + expect(leaf.node.parent).toBe(rootA.node); + + rootA.removeChild(wrapper); + rootB.insertChild(wrapper); + + expect(leaf.node.parent).toBe(rootB.node); + expect(leaf.node.x).toBe(50); + }); + + it('destroying a flattened element never hits the renderer', () => { + const el = createElement({ w: 100, h: 50 }); + + el.destroy(); + + expect(destroyNode).not.toHaveBeenCalled(); + }); + + it('does not rewrite positions of direct node.x writers on plain reparent', () => { + const rootA = createElement({ w: 1920, h: 1080, color: 0x000000ff }); + const rootB = createElement({ w: 1920, h: 1080, color: 0x111111ff }); + const scroller = createElement({ w: 500, h: 500, color: 0x222222ff }); + + rootA.insertChild(scroller); + // A scroll handler writes to the node directly, bypassing setNodeProp. + scroller.node.x = -640; + + rootA.removeChild(scroller); + rootB.insertChild(scroller); + + expect(scroller.node.x).toBe(-640); + }); + + it('folds a direct node.x/y write on a flattened content node to its children', () => { + const root = createElement({ w: 1920, h: 1080, color: 0x000000ff }); + // A layout-only content container (what a VirtualList scrolls) flattens. + const content = createElement({ w: 5000, h: 500 }); + const tileA = createElement({ w: 100, h: 100, color: 0xffffffff }); + const tileB = createElement({ w: 100, h: 100, color: 0xffffffff }); + + root.insertChild(content); + content.insertChild(tileA); + content.insertChild(tileB); + + tileA.setNodeProp('x', 0, false); + tileB.setNodeProp('x', 272, false); + + expect(content.isFlattened).toBe(true); + expect(tileA.node.x).toBe(0); + expect(tileB.node.x).toBe(272); + + // The scroll handler translates the content node directly, bypassing + // setProps. The placeholder can't paint, so the children must follow. + content.node.x = -300; + + expect(tileA.node.x).toBe(-300); + expect(tileB.node.x).toBe(-28); + + // A vertical write folds on its own axis. + content.node.y = -50; + + expect(tileA.node.y).toBe(-50); + expect(tileB.node.y).toBe(-50); + // The placeholder keeps the raw scroll offset it was handed. + expect(content.node.x).toBe(-300); + }); + it('materializes a flattened element when a deferred removal handler is attached', () => { + const root = createElement({ w: 1920, h: 1080, color: 0x000000ff }); + const wrapper = createElement({ w: 500, h: 500 }); + + root.insertChild(wrapper); + + expect(wrapper.isFlattened).toBe(true); + + // Reanimated sets this on unmount to fade the node out before destroying it. + // A placeholder can neither run nor finish an animation, so it has to become + // a real node first. + wrapper.deferNodeRemoval = () => {}; + + expect(wrapper.isFlattened).toBe(false); + expect( + (wrapper.node as unknown as { isFlattenedNode?: boolean }).isFlattenedNode, + ).toBeUndefined(); + }); + + it('tears down a flattened exit-animated subtree once its animation finishes', () => { + // parent(real) -> wrapper(layout-only, exit-animated) -> leaf(real image) + const root = createElement({ w: 1920, h: 1080, color: 0x000000ff }); + const wrapper = createElement({ w: 500, h: 500 }); + const leaf = createElement({ w: 100, h: 100, color: 0xffffffff }); + + root.insertChild(wrapper); + wrapper.insertChild(leaf); + + const leafNode = leaf.node; + + // Mirror createAnimatedComponent's exit path: run the animation, then + // destroy once it reports finished. + wrapper.deferNodeRemoval = (destroy) => { + wrapper.once('animationFinished', destroy); + wrapper.animateStyle('alpha', 0); + }; + + destroyNode.mockClear(); + wrapper.destroy(); + + // Without a real node the animation never finishes, the deferred destroy + // never runs, and the leaf leaks on the scene. It must be released. + expect(destroyNode).toHaveBeenCalledWith(leafNode); + }); +}); diff --git a/packages/react-lightning/src/element/LightningViewElement.roundedClip.spec.ts b/packages/react-lightning/src/element/LightningViewElement.roundedClip.spec.ts new file mode 100644 index 00000000..23f6aa51 --- /dev/null +++ b/packages/react-lightning/src/element/LightningViewElement.roundedClip.spec.ts @@ -0,0 +1,137 @@ +import type { RendererMain } from '@lightningjs/renderer'; +import type { Fiber } from 'react-reconciler'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import type { LightningViewElementProps, LightningViewElementStyle } from '../types'; +import { LightningViewElement } from './LightningViewElement'; + +function createMockNode(props: Record = {}) { + return { + x: 0, + y: 0, + w: 0, + h: 0, + alpha: 1, + color: 0, + clipRadius: 0, + shader: { props: {} }, + parent: null, + on() {}, + off() {}, + animate() { + return { once() {}, start() {} }; + }, + destroy() {}, + ...props, + }; +} + +const renderer = { + createNode: (props: Record) => createMockNode(props), + createTextNode: (props: Record) => createMockNode(props), + createShader: () => ({ props: {} }), + createTexture: () => ({}), + destroyNode() {}, +} as unknown as RendererMain; + +function createElement(style: Partial) { + const props = { + style, + } as LightningViewElementProps; + + return new LightningViewElement(props, renderer, [], {} as Fiber); +} + +// setProps stages the update and flushes on a microtask. +const flush = () => Promise.resolve(); + +describe('rounded clipping (borderRadius + clipping -> clipRadius)', () => { + beforeEach(() => { + LightningViewElement.roundedClippingEnabled = true; + }); + + afterEach(() => { + LightningViewElement.roundedClippingEnabled = false; + }); + + it('does nothing while the roundedClipping option is off (default)', () => { + LightningViewElement.roundedClippingEnabled = false; + + const el = createElement({ w: 100, h: 50, clipping: true, borderRadius: 16 }); + + expect(el.node.clipRadius || 0).toBe(0); + }); + + it('sets clipRadius when clipping and borderRadius are both set at mount', () => { + const el = createElement({ w: 100, h: 50, clipping: true, borderRadius: 16 }); + + expect(el.node.clipRadius).toBe(16); + }); + + it('does not clip for borderRadius alone', () => { + const el = createElement({ w: 100, h: 50, borderRadius: 16 }); + + expect(el.node.clipRadius || 0).toBe(0); + }); + + it('does not clip for clipping alone', () => { + const el = createElement({ w: 100, h: 50, clipping: true }); + + expect(el.node.clipRadius || 0).toBe(0); + }); + + it('sets clipRadius when clipping arrives after mount (fast path)', async () => { + const el = createElement({ w: 100, h: 50, borderRadius: 16 }); + + // Imperative toggle: a marked-partial push, so the Rounded shader is kept + // and its radius drives clipRadius. + el.style.clipping = true; + await flush(); + + expect(el.node.clipRadius).toBe(16); + }); + + it('clears clipRadius when the borderRadius is removed', async () => { + const el = createElement({ w: 100, h: 50, clipping: true, borderRadius: 16 }); + + el.setProps({ style: { borderRadius: 0 } }); + await flush(); + + expect(el.node.clipRadius).toBe(0); + }); + + it('keeps clipping with a rounded border (RoundedWithBorder shader)', () => { + const el = createElement({ + w: 100, + h: 50, + clipping: true, + borderRadius: 16, + border: { w: 2, color: 0xffffffff }, + }); + + expect(el.node.clipRadius).toBe(16); + }); + + it('clips a per-corner radius array to the largest corner', () => { + const el = createElement({ + w: 100, + h: 50, + clipping: true, + borderRadius: [4, 8, 24, 12] as unknown as number, + }); + + expect(el.node.clipRadius).toBe(24); + }); + + it('leaves the node color alone (stencil clip, no tint games)', () => { + const el = createElement({ + w: 100, + h: 50, + clipping: true, + borderRadius: 16, + color: 0x1c1f26ff, + }); + + expect(el.node.color).toBe(0x1c1f26ff); + }); +}); diff --git a/packages/react-lightning/src/element/LightningViewElement.spec.ts b/packages/react-lightning/src/element/LightningViewElement.spec.ts new file mode 100644 index 00000000..830213ea --- /dev/null +++ b/packages/react-lightning/src/element/LightningViewElement.spec.ts @@ -0,0 +1,331 @@ +import type { RendererMain } from '@lightningjs/renderer'; +import type { Fiber } from 'react-reconciler'; +import { describe, expect, it } from 'vitest'; + +import type { LightningViewElementProps, LightningViewElementStyle } from '../types'; +import { LightningViewElement } from './LightningViewElement'; + +type ThreeChildren = [ + LightningViewElement, + LightningViewElement, + LightningViewElement, +]; + +// A minimal stand-in for a renderer CoreNode: just the props the element +// reads/writes plus no-op event/animation hooks. +function createMockNode(props: Record = {}) { + return { + x: 0, + y: 0, + w: 0, + h: 0, + alpha: 1, + color: 0, + shader: { props: {} }, + parent: null, + on() {}, + off() {}, + animate() { + return { once() {}, start() {} }; + }, + destroy() {}, + ...props, + }; +} + +const renderer = { + createNode: (props: Record) => createMockNode(props), + createTextNode: (props: Record) => createMockNode(props), + createShader: () => ({ props: {} }), + createTexture: () => ({}), + destroyNode() {}, +} as unknown as RendererMain; + +function createElement(style: Partial) { + const props = { + style, + } as LightningViewElementProps; + + return new LightningViewElement(props, renderer, [], {} as Fiber); +} + +// setProps stages the update and flushes on a microtask. +const flush = () => Promise.resolve(); + +describe('LightningViewElement paint withholding', () => { + it('hides a definite-sized node until its first layout resolves', () => { + const el = createElement({ w: 100, h: 50, alpha: 1 }); + + el.withholdPaintUntilLayout(); + + expect(el.paintWithheld).toBe(true); + expect(el.node.alpha).toBe(0); + expect(el.visible).toBe(false); + + el.emitLayoutEvent(); + + expect(el.paintWithheld).toBe(false); + expect(el.hasLayout).toBe(true); + expect(el.node.alpha).toBe(1); + expect(el.visible).toBe(true); + }); + + it('restores the originally styled alpha (not 1) on reveal', () => { + const el = createElement({ w: 100, h: 50, alpha: 0.5 }); + + el.withholdPaintUntilLayout(); + expect(el.node.alpha).toBe(0); + + el.emitLayoutEvent(); + expect(el.node.alpha).toBe(0.5); + }); + + it('is a no-op for a zero-sized node (nothing to flash)', () => { + const el = createElement({ alpha: 1 }); + + el.withholdPaintUntilLayout(); + + expect(el.paintWithheld).toBe(false); + expect(el.node.alpha).toBe(1); + }); + + it('is a no-op for an already-invisible node', () => { + const el = createElement({ w: 100, h: 50, alpha: 0 }); + + el.withholdPaintUntilLayout(); + + expect(el.paintWithheld).toBe(false); + expect(el.node.alpha).toBe(0); + }); + + it('is a no-op once a layout has already resolved', () => { + const el = createElement({ w: 100, h: 50, alpha: 1 }); + + el.emitLayoutEvent(); + el.withholdPaintUntilLayout(); + + expect(el.paintWithheld).toBe(false); + expect(el.node.alpha).toBe(1); + }); + + it('keeps the node hidden but records a styled alpha change made while withheld', async () => { + const el = createElement({ w: 100, h: 50, alpha: 1 }); + + el.withholdPaintUntilLayout(); + expect(el.node.alpha).toBe(0); + + // App changes alpha before the first layout arrives — the node must stay + // hidden, but reveal at the new value. + el.setProps({ style: { alpha: 0.25 } }); + await flush(); + + expect(el.node.alpha).toBe(0); + expect(el.paintWithheld).toBe(true); + + el.emitLayoutEvent(); + expect(el.node.alpha).toBe(0.25); + }); + + it('reveals immediately when released before a layout (e.g. detached by a boundary)', () => { + const el = createElement({ w: 100, h: 50, alpha: 1 }); + + el.withholdPaintUntilLayout(); + expect(el.node.alpha).toBe(0); + + el.releaseWithheldPaint(); + + expect(el.paintWithheld).toBe(false); + expect(el.node.alpha).toBe(1); + // Released without a layout — still not laid out. + expect(el.hasLayout).toBe(false); + }); +}); + +describe('LightningViewElement border shader', () => { + // A renderer whose createShader returns a tagged shader so we can assert the + // node actually received it. + const borderShader = { props: {}, type: 'Border' }; + const shaderRenderer = { + createNode: (props: Record) => createMockNode(props), + createTextNode: (props: Record) => createMockNode(props), + createShader: () => borderShader, + createTexture: () => ({}), + destroyNode() {}, + } as unknown as RendererMain; + + function createShaderElement(style: Partial) { + const props = { + style, + } as LightningViewElementProps; + + return new LightningViewElement(props, shaderRenderer, [], {} as Fiber); + } + + it('paints a border shader when one is added to an already-mounted node, then clears it on removal', async () => { + // Starts with no border — the focus-ring case toggles it on later. + const el = createShaderElement({ w: 100, h: 50 }); + + // Add a border (e.g. a focus ring). Without `border` forcing the slow path + // this would silently fast-path and never create a shader. + el.setProps({ + style: { w: 100, h: 50, border: { w: 4, color: 0xffffffff } }, + }); + await flush(); + expect(el.node.shader).toBe(borderShader); + + // Remove the border (blur). The shader must be cleared, not left painting. + el.setProps({ style: { w: 100, h: 50 } }); + await flush(); + expect(el.node.shader).toBeNull(); + }); +}); + +describe('LightningViewElement no-op animation skip', () => { + function createSpyElement(style: Partial) { + let animateCalls = 0; + const node = createMockNode({ + animate() { + animateCalls++; + + return { once() {}, start() {} }; + }, + }); + const spyRenderer = { + createNode: () => node, + createTextNode: () => node, + createShader: () => ({ props: {} }), + createTexture: () => ({}), + destroyNode() {}, + } as unknown as RendererMain; + const el = new LightningViewElement( + { style } as LightningViewElementProps, + spyRenderer, + [], + {} as Fiber, + ); + + return { el, node, calls: () => animateCalls }; + } + + it('skips animating a prop to its current value', () => { + const { el, node, calls } = createSpyElement({ alpha: 1 }); + + node.alpha = 1; + el.animateStyle('alpha', 1); + + expect(calls()).toBe(0); + expect(node.alpha).toBe(1); + }); + + it('still animates a real value change', () => { + const { el, node, calls } = createSpyElement({ alpha: 1 }); + + node.alpha = 1; + el.animateStyle('alpha', 0.4); + + expect(calls()).toBe(1); + }); + + it('does not skip when an in-flight animation targets a different value', () => { + const { el, node, calls } = createSpyElement({ alpha: 1 }); + + // In-flight: alpha animating toward 0. + el.animateStyle('alpha', 0); + expect(calls()).toBe(1); + + // Node happens to sit at 0.5 mid-animation; a request for 0.5 must still + // start a new animation (otherwise the old one keeps running to 0). + (node as { alpha: number }).alpha = 0.5; + el.animateStyle('alpha', 0.5); + expect(calls()).toBe(2); + }); + + it('skips a repeat of the same in-flight target once the value arrived', () => { + const { el, node, calls } = createSpyElement({ alpha: 1 }); + + el.animateStyle('alpha', 0); + expect(calls()).toBe(1); + + (node as { alpha: number }).alpha = 0; + el.animateStyle('alpha', 0); + expect(calls()).toBe(1); + }); +}); + +describe('LightningViewElement same-parent child moves', () => { + function createParentAndChildren(count: number) { + const parent = createElement({}); + const children = Array.from({ length: count }, () => createElement({})); + + for (const child of children) { + parent.insertChild(child); + } + + return { parent, children }; + } + + it('reorders children[] when a same-parent move targets an earlier sibling', () => { + const { parent, children } = createParentAndChildren(3); + const [a, b, c] = children as ThreeChildren; + + // Move c before a — a keyed reorder React performs via insertBefore. + parent.insertChild(c, a); + + expect(parent.children).toEqual([c, a, b]); + }); + + it('reorders children[] when a same-parent move targets the end', () => { + const { parent, children } = createParentAndChildren(3); + const [a, b, c] = children as ThreeChildren; + + // Move a to the end (no beforeChild). + parent.insertChild(a); + + expect(parent.children).toEqual([b, c, a]); + }); + + it('emits childMoved (not childAdded/childRemoved) so the flexbox plugin reindexes without tearing down the node', () => { + const { parent, children } = createParentAndChildren(3); + const [a, , c] = children as ThreeChildren; + + const moved: Array<[unknown, number, number]> = []; + const added: unknown[] = []; + const removed: unknown[] = []; + + parent.on('childMoved', (child, fromIndex, toIndex) => { + moved.push([child, fromIndex, toIndex]); + }); + parent.on('childAdded', (child) => added.push(child)); + parent.on('childRemoved', (child) => removed.push(child)); + + parent.insertChild(c, a); + + expect(moved).toEqual([[c, 2, 0]]); + expect(added).toEqual([]); + expect(removed).toEqual([]); + }); + + it('does not touch the child node parent/lifecycle on a same-parent move', () => { + const { parent, children } = createParentAndChildren(3); + const [a, , c] = children as ThreeChildren; + const nodeBefore = c.node; + + parent.insertChild(c, a); + + expect(c.parent).toBe(parent); + expect(c.node).toBe(nodeBefore); + }); + + it('is a no-op inserting a child before itself', () => { + const { parent, children } = createParentAndChildren(3); + const [a, b, c] = children as ThreeChildren; + + const moved: unknown[] = []; + parent.on('childMoved', () => moved.push(true)); + + parent.insertChild(b, b); + + expect(parent.children).toEqual([a, b, c]); + expect(moved).toEqual([]); + }); +}); diff --git a/packages/react-lightning/src/element/LightningViewElement.ts b/packages/react-lightning/src/element/LightningViewElement.ts index 9d617dcf..604fb15e 100644 --- a/packages/react-lightning/src/element/LightningViewElement.ts +++ b/packages/react-lightning/src/element/LightningViewElement.ts @@ -14,8 +14,12 @@ import type { } from '@lightningjs/renderer'; import type { Fiber } from 'react-reconciler'; import { EventEmitter, type IEventEmitter } from 'tseep'; +import { PARTIAL_STYLE } from './partialStyle'; -import { getNodeResizeObserver, type NodeResizeObserver } from '../observer/NodeResizeObserver'; +import { + getNodeResizeObserver, + type NodeResizeObserver, +} from '../observer/NodeResizeObserver'; import type { Plugin } from '../render/Plugin'; import { type Focusable, @@ -31,6 +35,7 @@ import { type TextureDef, } from '../types'; import { AllStyleProps } from './AllStyleProps'; +import { createFlattenedNode } from './FlattenedRendererNode'; const __bannedProps: Record = {}; let __bannedPropsInitialized = false; @@ -62,16 +67,54 @@ function __checkProps(props: string[]) { } } -function createTexture(renderer: RendererMain, textureDef: TextureDef): Texture { +function createTexture( + renderer: RendererMain, + textureDef: TextureDef, +): Texture { return renderer.createTexture(textureDef.type, textureDef.props); } let idCounter = 0; +// Returned when a requested animation is a proven no-op; satisfies the +// controller contract without registering anything with the renderer. +const noopAnimationController = { + state: 'stopped', + start() { + return this; + }, + stop() { + return this; + }, + pause() { + return this; + }, + restore() { + return this; + }, + waitUntilStopped() { + return Promise.resolve(); + }, + on() { + return this; + }, + once() { + return this; + }, + off() { + return this; + }, + emit() { + return this; + }, +} as unknown as IAnimationController; + export class LightningViewElement< TStyleProps extends LightningViewElementStyle = LightningViewElementStyle, - TProps extends LightningViewElementProps = LightningViewElementProps, -> implements Focusable { + TProps extends + LightningViewElementProps = LightningViewElementProps, +> implements Focusable +{ public static allElements: Record = {}; public readonly id: number; @@ -98,10 +141,113 @@ export class LightningViewElement< private _recycled = false; private _hasStagedUpdates = false; private _hasLayout = false; + /** Last requested animation target per style key, for the no-op-skip guard. */ + private _animTargets = new Map(); + private _paintWithheld = false; + private _withheldAlpha = 1; private _eventEmitter = new EventEmitter(); private _deferTarget: LightningElement | null = null; - private _deferNodeRemovalHandler: ((destroy: () => void) => void) | null = null; + private _deferNodeRemovalHandler: ((destroy: () => void) => void) | null = + null; private _resizeObserver: NodeResizeObserver | null = null; + /** Rounded clipping (borderRadius + clipping -> clipRadius) opt-in; set by createRoot. */ + public static roundedClippingEnabled = false; + + /** + * When true (set from RenderOptions.flattenLayoutViews), layout-only Views + * skip renderer node creation entirely: the element keeps a placeholder + * node, descendants attach to the nearest materialized ancestor, and layout + * positions accumulate across the flattened chain. + */ + public static flattenLayoutViewsEnabled = false; + + // Node props with render semantics. Anything else that lands on the node + // (RN-layer junk like handlers, testID, fsTagName) is inert and a flattened + // placeholder can carry it just as well. + private static readonly _visualNodeProps = new Set([ + 'color', + 'colorTop', + 'colorBottom', + 'colorLeft', + 'colorRight', + 'colorTl', + 'colorTr', + 'colorBl', + 'colorBr', + 'alpha', + 'shader', + 'texture', + 'src', + 'text', + 'clipping', + 'clipRadius', + 'rtt', + 'zIndex', + 'zIndexLocked', + 'scale', + 'scaleX', + 'scaleY', + 'rotation', + 'pivot', + 'pivotX', + 'pivotY', + 'mount', + 'mountX', + 'mountY', + 'autosize', + 'imageType', + 'srcX', + 'srcY', + 'srcWidth', + 'srcHeight', + 'strictBounds', + 'boundsMargin', + ]); + + // A visual prop at its neutral value still needs no node (color 0 is + // stamped on every mount, reanimated pushes alpha 1 constantly). + private static _needsRealNode(key: string, value: unknown): boolean { + if (!LightningViewElement._visualNodeProps.has(key) || value == null) { + return false; + } + + switch (key) { + case 'color': + case 'rotation': + case 'clipRadius': + case 'zIndex': + return value !== 0; + case 'alpha': + case 'scale': + case 'scaleX': + case 'scaleY': + return value !== 1; + case 'clipping': + case 'rtt': + case 'zIndexLocked': + return value !== false; + default: + return true; + } + } + + private static _isLayoutOnlyNodeProps(props: Record): boolean { + for (const key in props) { + if (LightningViewElement._needsRealNode(key, props[key])) { + return false; + } + } + + return true; + } + + private _flattened = false; + // Parent-relative layout position (what layout/styles asked for); node.x/y + // may differ by the accumulated offsets of flattened ancestors. + public _layoutX = 0; + public _layoutY = 0; + public _flatOffsetX = 0; + public _flatOffsetY = 0; private _isObservingResize = false; public get visible(): boolean { @@ -133,6 +279,171 @@ export class LightningViewElement< this._recycled = value; } + public get isFlattened(): boolean { + return this._flattened; + } + + /** The renderer node this element's real descendants attach to. */ + public _hostNode(): RendererNode | null { + if (!this._flattened) { + return this.node as RendererNode; + } + + return this._parent ? this._parent._hostNode() : null; + } + + /** + * (Re)link this element below a (possibly flattened) parent: absorb the + * accumulated offsets of the flattened chain above and attach the real node + * to `host`. Recurses through flattened elements to the real-node frontier. + * `force` relinks the host even when offsets are unchanged (reparent, + * materialize); positions are only rewritten when offsets changed, so + * direct node.x writers (scroll) are left alone. + */ + public _applyFlattenedLink( + offsetX: number, + offsetY: number, + host: RendererNode | null, + force: boolean, + ): void { + const offsetsChanged = + this._flatOffsetX !== offsetX || this._flatOffsetY !== offsetY; + + if (!force && !offsetsChanged) { + return; + } + + this._flatOffsetX = offsetX; + this._flatOffsetY = offsetY; + + if (this._flattened) { + this._refreshFlattenedChildren(force); + + return; + } + + if (this.node.parent !== host) { + this.node.parent = host; + } + + if (offsetsChanged) { + this.node.x = this._layoutX + offsetX; + this.node.y = this._layoutY + offsetY; + } + } + + // Push this flattened element's fold (own offsets + own layout position) + // down to its children. + private _refreshFlattenedChildren(force: boolean): void { + const offsetX = this._flatOffsetX + this._layoutX; + const offsetY = this._flatOffsetY + this._layoutY; + const host = this._hostNode(); + + for (let i = 0; i < this.children.length; i++) { + this.children[i]?._applyFlattenedLink(offsetX, offsetY, host, force); + } + } + + /** Write a layout axis, folding flattened-ancestor offsets into the node. */ + private _writeAxis(key: 'x' | 'y', value: number): boolean { + const previous = key === 'x' ? this._layoutX : this._layoutY; + + if (key === 'x') { + this._layoutX = value; + } else { + this._layoutY = value; + } + + if (this._flattened) { + if (previous === value) { + return false; + } + + this.node[key] = value; + this._refreshFlattenedChildren(false); + + return true; + } + + const applied = + value + (key === 'x' ? this._flatOffsetX : this._flatOffsetY); + + if (this.node[key] === applied) { + return false; + } + + this.node[key] = applied; + + return true; + } + + /** + * A direct write to a flattened placeholder's x/y (a scroll handler moving + * the content node straight through node.x, bypassing setProps) has to fold + * through to the hoisted children. The placeholder forwards the write here. + */ + public onFlattenedAxisWrite(axis: 'x' | 'y', value: number): void { + if (axis === 'x') { + if (this._layoutX === value) { + return; + } + + this._layoutX = value; + } else { + if (this._layoutY === value) { + return; + } + + this._layoutY = value; + } + + this._refreshFlattenedChildren(false); + } + + /** + * Swap the flattened placeholder for a real renderer node. Triggered by the + * first prop that needs one (a background, border, alpha, interaction + * visual). Sticky: once materialized an element never re-flattens, so a + * style that toggles per focus doesn't churn nodes. + */ + public _materialize(): void { + if (!this._flattened) { + return; + } + + this._flattened = false; + + const placeholder = this.node; + const node = this._createNode({ + x: this._layoutX + this._flatOffsetX, + y: this._layoutY + this._flatOffsetY, + w: placeholder.w, + h: placeholder.h, + alpha: placeholder.alpha, + // The renderer's default node color is white; mount stamps 0 on every + // element and so must we. + color: 0, + }); + + if (import.meta.env.DEV) { + node.__reactNode = this; + } + + node.__reactFiber = placeholder.__reactFiber; + node.parent = this._parent ? this._parent._hostNode() : null; + node.on('inViewport', this._onInViewport); + node.on('loaded', this._onTextureLoaded); + node.on('failed', this._onTextureFailed); + + this.node = node; + + for (let i = 0; i < this.children.length; i++) { + this.children[i]?._applyFlattenedLink(0, 0, node, true); + } + + this.recalculateVisibility(); + } + public set focusable(value: boolean) { if (this._focusable === value) { return; @@ -161,11 +472,9 @@ export class LightningViewElement< } public set shader(shader: INode['shader'] | null) { - if (shader === null) { - // TODO: Unset shader? - } else { - this.node.shader = shader; - } + // A null shader resets the node to the stage's default shader (CoreNode + // handles the null case), letting callers clear a previously-set shader. + this.node.shader = shader as INode['shader']; } public get parent(): LightningElement | null { @@ -173,12 +482,29 @@ export class LightningViewElement< } public set parent(parent) { - if (parent && this._parent === parent && this._parent.node === parent.node) { + if ( + parent && + this._parent === parent && + this._parent.node === parent.node + ) { return; } this._parent = parent; - this.node.parent = parent?.node ?? null; + + if (LightningViewElement.flattenLayoutViewsEnabled) { + const host = parent ? parent._hostNode() : null; + const offsetX = parent?.isFlattened + ? parent._flatOffsetX + parent._layoutX + : 0; + const offsetY = parent?.isFlattened + ? parent._flatOffsetY + parent._layoutY + : 0; + + this._applyFlattenedLink(offsetX, offsetY, host, true); + } else { + this.node.parent = parent?.node ?? null; + } this.recalculateVisibility(); } @@ -210,6 +536,12 @@ export class LightningViewElement< * before the node is removed. */ public set deferNodeRemoval(handler: ((destroy: () => void) => void) | null) { + // A deferred handler animates the node out then destroys it; a placeholder + // can't animate or signal done, so materialize or the subtree leaks. + if (handler && this._flattened) { + this._materialize(); + } + this._deferNodeRemovalHandler = handler; this.deferTarget = handler ? this : null; @@ -261,6 +593,59 @@ export class LightningViewElement< return this._hasLayout; } + public get paintWithheld(): boolean { + return this._paintWithheld; + } + + /** + * Hide this node (force rendered alpha to 0) until its first layout resolves, + * then restore the styled alpha. Flex layout is computed asynchronously (in a + * worker), so without this a node with a definite size mounts and paints at + * its pre-layout origin (0,0) for one or more frames before the layout result + * moves it — the "async-flex origin flash". Withholding paint until + * {@link _onLayout} fires removes that flash regardless of how long the + * layout round-trip takes. + * + * No-op once laid out, and a no-op for nodes that can't flash anyway (already + * invisible, or zero-sized — those paint nothing at their origin), so the + * common 0x0 mass-mount path is untouched. + */ + public withholdPaintUntilLayout(): void { + if (this._hasLayout || this._paintWithheld) { + return; + } + + const node = this.node; + + if (node.alpha <= 0 || node.w <= 0 || node.h <= 0) { + return; + } + + this._paintWithheld = true; + this._withheldAlpha = node.alpha; + node.alpha = 0; + this.recalculateVisibility(); + } + + /** + * Restore a withheld node's alpha immediately, without waiting for a layout. + * Used when a node leaves flex layout before its first layout resolves (e.g. + * its subtree is detached by a boundary) and so would otherwise never be + * revealed. + */ + public releaseWithheldPaint(): void { + if (!this._paintWithheld) { + return; + } + + this._paintWithheld = false; + + if (this.node.alpha !== this._withheldAlpha) { + this.node.alpha = this._withheldAlpha; + this.recalculateVisibility(); + } + } + public constructor( initialProps: TProps, renderer: RendererMain, @@ -295,13 +680,37 @@ export class LightningViewElement< const lngProps = this._toLightningNodeProps(this.props, true); - this._styleProxy = new Proxy(this.props.style ?? {}, this._styleProxyHandler); + this._styleProxy = new Proxy( + this.props.style ?? {}, + this._styleProxyHandler, + ); if (import.meta.env.DEV) { __checkProps(Object.keys(lngProps)); } - this.node = this._createNode(lngProps); + if ( + LightningViewElement.flattenLayoutViewsEnabled && + !this.isTextElement && + !this.isImageElement && + this.props.transition === undefined && + LightningViewElement._isLayoutOnlyNodeProps( + lngProps as Record, + ) + ) { + this._flattened = true; + this.node = createFlattenedNode( + lngProps as Record, + ); + (this.node as unknown as { owner: LightningViewElement }).owner = this; + } else { + this.node = this._createNode(lngProps); + } + + if (LightningViewElement.flattenLayoutViewsEnabled) { + this._layoutX = typeof lngProps.x === 'number' ? lngProps.x : 0; + this._layoutY = typeof lngProps.y === 'number' ? lngProps.y : 0; + } if (import.meta.env.DEV) { this.node.__reactNode = this; @@ -342,7 +751,7 @@ export class LightningViewElement< this._deferNodeRemovalHandler(() => { this.emit('deferredDestroyComplete'); }); - } else if (!this._deferTarget) { + } else if (!this._deferTarget && !this._flattened) { this._renderer.destroyNode(this.node); } @@ -351,7 +760,9 @@ export class LightningViewElement< this._eventEmitter.emit('destroy'); } - public on = (...args: Parameters['on']>): (() => void) => { + public on = ( + ...args: Parameters['on']> + ): (() => void) => { this._eventEmitter.on(...args); if (args[0] === 'resized') { @@ -400,7 +811,13 @@ export class LightningViewElement< for (let i = 0; i < this.children.length; i++) { const child = this.children[i]; - if (child) { + if (!child) { + continue; + } + + if (LightningViewElement.flattenLayoutViewsEnabled) { + child._applyFlattenedLink(0, 0, node, true); + } else { child.node.parent = node; } } @@ -410,12 +827,21 @@ export class LightningViewElement< this.recalculateVisibility(); } - public insertChild(child: LightningElement, beforeChild?: LightningElement | null): void { + public insertChild( + child: LightningElement, + beforeChild?: LightningElement | null, + ): void { if (child.parent === this && child.parent.node === this.node) { + // Already ours: a same-parent reorder, not a real (re)parent. Reshuffle + // children[] only, keep the node/Yoga subtree alive. + this._moveChild(child, beforeChild); + return; } - const index = beforeChild ? this.children.indexOf(beforeChild) : this.children.length; + const index = beforeChild + ? this.children.indexOf(beforeChild) + : this.children.length; if (beforeChild) { this.children.splice(index, 0, child); @@ -432,6 +858,35 @@ export class LightningViewElement< this._eventEmitter.emit('childAdded', child, index); } + private _moveChild( + child: LightningElement, + beforeChild?: LightningElement | null, + ): void { + if (child === beforeChild) { + return; + } + + const fromIndex = this.children.indexOf(child); + + if (fromIndex < 0) { + return; + } + + this.children.splice(fromIndex, 1); + + // Look up beforeChild after removing child, so its index already is the + // destination position in the shortened array. + const toIndex = beforeChild + ? this.children.indexOf(beforeChild) + : this.children.length; + + this.children.splice(toIndex, 0, child); + + if (toIndex !== fromIndex) { + this._eventEmitter.emit('childMoved', child, fromIndex, toIndex); + } + } + public removeChild(child: LightningElement): void { const index = this.children.indexOf(child); @@ -489,7 +944,25 @@ export class LightningViewElement< totalX += curr.node.x; totalY += curr.node.y; - curr = curr.parent; + let next: LightningElement | null = curr.parent; + + // A real node's x already folds in its flattened ancestors' offsets; + // skip those so they don't double-count. If the requested ancestor IS + // one of them, back its own fold out of the running total. + if (LightningViewElement.flattenLayoutViewsEnabled && !curr.isFlattened) { + while (next && next.isFlattened) { + if (next === ancestor) { + totalX -= next._flatOffsetX + next._layoutX; + totalY -= next._flatOffsetY + next._layoutY; + next = null; + break; + } + + next = next.parent; + } + } + + curr = next; } return { @@ -574,6 +1047,23 @@ export class LightningViewElement< __checkProps([key]); } + if (LightningViewElement.flattenLayoutViewsEnabled) { + if ( + this._flattened && + LightningViewElement._needsRealNode(key as string, value) + ) { + this._materialize(); + } + + if ( + (key === 'x' || key === 'y') && + typeof value === 'number' && + !(animate && this.props.transition?.[key as keyof TStyleProps]) + ) { + return this._writeAxis(key as 'x' | 'y', value); + } + } + if (this.node[key] === value) { return false; } @@ -598,9 +1088,12 @@ export class LightningViewElement< } public emitLayoutEvent(): void { + // onLayout stays parent-relative; node.x may fold in flattened-ancestor + // offsets. + const flag = LightningViewElement.flattenLayoutViewsEnabled; const dimensions = { - x: this.node.x, - y: this.node.y, + x: flag ? this._layoutX : this.node.x, + y: flag ? this._layoutY : this.node.y, h: this.node.h, w: this.node.w, }; @@ -624,7 +1117,8 @@ export class LightningViewElement< const prevFocusable = this.focusable; const prevVisible = this._visible; - this._visible = this.node.alpha > 0 && (!this.parent || this.parent.visible); + this._visible = + this.node.alpha > 0 && (!this.parent || this.parent.visible); if (this._visible !== prevVisible) { this._eventEmitter.emit('visibilityChanged', this._visible); @@ -646,15 +1140,49 @@ export class LightningViewElement< key: K, value: TStyleProps[K], ): IAnimationController { + // Skip no-op animations (target equals the node's current value, and no + // in-flight animation is heading somewhere else). A no-op still counts as + // an active animation for delay+duration, keeping the scene hot and + // full-redrawing every frame; focus moves fire several (e.g. a popover's + // delayed alpha 1 -> 1) and stall low-end devices for their whole window. + const inFlight = this._animTargets.get(key); + + if ( + (this.node as unknown as Record)[key] === value && + (inFlight === undefined || inFlight === value) + ) { + return noopAnimationController; + } + + this._animTargets.set(key, value); + + let target = value; + + if ( + LightningViewElement.flattenLayoutViewsEnabled && + (key === 'x' || key === 'y') && + typeof value === 'number' + ) { + if (key === 'x') { + this._layoutX = value; + target = (value + this._flatOffsetX) as TStyleProps[K]; + } else { + this._layoutY = value; + target = (value + this._flatOffsetY) as TStyleProps[K]; + } + } + return this._createAnimation( { - [key]: value, + [key]: target, }, this.props.transition?.[key], ).start(); } - public animateShader(props: Partial): IAnimationController { + public animateShader( + props: Partial, + ): IAnimationController { return this._createAnimation( { shaderProps: props, @@ -670,11 +1198,15 @@ export class LightningViewElement< private _destroyFinalize = () => { this._deferTarget?.off('deferredDestroyComplete', this._destroyFinalize); this.node.parent = null; - this._renderer.destroyNode(this.node); + + if (!this._flattened) { + this._renderer.destroyNode(this.node); + } }; private _reconcileResizeObserving(): void { - const shouldObserve = this.props.onResize != null || this._eventEmitter.hasListeners('resized'); + const shouldObserve = + this.props.onResize != null || this._eventEmitter.hasListeners('resized'); if (shouldObserve === this._isObservingResize) { return; @@ -694,7 +1226,10 @@ export class LightningViewElement< } // Don't pass down the `data` prop to the lightning node. - private _createNode({ data: _data, ...props }: Partial): RendererNode { + private _createNode({ + data: _data, + ...props + }: Partial): RendererNode { const node = this.isTextElement ? this._renderer.createTextNode(props) : this._renderer.createNode(props); @@ -772,8 +1307,53 @@ export class LightningViewElement< delete lngProps.h; } + // While paint is withheld, a styled alpha change must update the alpha we + // restore on first layout, not the node's (which stays 0 so the node keeps + // hiding). See {@link withholdPaintUntilLayout}. + if (this._paintWithheld && lngProps.alpha !== undefined) { + this._withheldAlpha = lngProps.alpha; + delete lngProps.alpha; + } + + let flattenedMoved = false; + + if (LightningViewElement.flattenLayoutViewsEnabled) { + if ( + this._flattened && + (this.props.transition !== undefined || + !LightningViewElement._isLayoutOnlyNodeProps( + lngProps as Record, + )) + ) { + this._materialize(); + } + + if (typeof lngProps.x === 'number') { + flattenedMoved = this._flattened && this._layoutX !== lngProps.x; + this._layoutX = lngProps.x; + + if (!this._flattened) { + lngProps.x += this._flatOffsetX; + } + } + + if (typeof lngProps.y === 'number') { + flattenedMoved = + flattenedMoved || (this._flattened && this._layoutY !== lngProps.y); + this._layoutY = lngProps.y; + + if (!this._flattened) { + lngProps.y += this._flatOffsetY; + } + } + } + Object.assign(this.node, lngProps); + if (flattenedMoved) { + this._refreshFlattenedChildren(false); + } + // oxlint-disable-next-line typescript/no-explicit-any -- Required for accessing AllStyleProps symbol Object.assign((this.style as any)[AllStyleProps], this.props.style); @@ -792,7 +1372,10 @@ export class LightningViewElement< } if (hasStyleChanges) { - this._eventEmitter.emit('stylesChanged', this.props.style as Partial); + this._eventEmitter.emit( + 'stylesChanged', + this.props.style as Partial, + ); } this._isUpdateQueued = false; @@ -802,11 +1385,14 @@ export class LightningViewElement< /** Style properties that may trigger shader creation — must use the slow path. */ private static readonly _shaderStyleProps = new Set([ + 'border', + 'borderColor', 'borderRadius', 'borderTop', 'borderLeft', 'borderRight', 'borderBottom', + 'linearGradient', ]); /** @@ -819,6 +1405,14 @@ export class LightningViewElement< return false; } + // A node that currently has a shader must take the slow path: the update + // may remove the border/radius that produced it, and only the slow path + // recomputes (or clears) the shader. The fast path assigns style keys to + // the node verbatim and would leave a stale shader painting. + if (this._shaderDef) { + return false; + } + for (const key in payload) { if (key !== 'style') { return false; @@ -854,6 +1448,21 @@ export class LightningViewElement< */ private _applyStyleFastPath(payload: Partial): boolean { const style = payload.style as Partial; + + if (LightningViewElement.flattenLayoutViewsEnabled && this._flattened) { + for (const key in style) { + if ( + LightningViewElement._needsRealNode( + key, + style[key as keyof TStyleProps], + ) + ) { + this._materialize(); + break; + } + } + } + const previousOpacity = this.node.alpha; let changed = false; @@ -881,8 +1490,22 @@ export class LightningViewElement< continue; } + // While paint is withheld, capture a styled alpha change for restore on + // first layout instead of un-hiding the node. See + // {@link withholdPaintUntilLayout}. + if (key === 'alpha' && this._paintWithheld) { + this._withheldAlpha = value as number; + continue; + } + if (transition?.[typedKey]) { this.animateStyle(typedKey, value as TStyleProps[typeof typedKey]); + } else if ( + LightningViewElement.flattenLayoutViewsEnabled && + (key === 'x' || key === 'y') && + typeof value === 'number' + ) { + this._writeAxis(key as 'x' | 'y', value); } else { // oxlint-disable-next-line typescript/no-explicit-any -- direct node property assignment (this.node as any)[key] = value; @@ -892,11 +1515,32 @@ export class LightningViewElement< // oxlint-disable-next-line typescript/no-explicit-any -- Required for accessing AllStyleProps symbol Object.assign((this.style as any)[AllStyleProps], style); + // A direct `clipping` change must keep rounded clipping in sync. Shader + // props force the slow path, so the shader is stable here. + if (LightningViewElement.roundedClippingEnabled && 'clipping' in style) { + const shaderType = this._shaderDef?.type; + const radius = + style.clipping === true && + (shaderType === 'Rounded' || shaderType === 'RoundedWithBorder') + ? this._shaderDef?.props?.radius + : 0; + const clipRadius = + (Array.isArray(radius) ? Math.max(...radius) : (radius as number)) || + 0; + + if ((this.node.clipRadius ?? 0) !== clipRadius) { + this.node.clipRadius = clipRadius; + } + } + if (previousOpacity !== this.node.alpha) { this.recalculateVisibility(); } - this._eventEmitter.emit('stylesChanged', this.props.style as Partial); + this._eventEmitter.emit( + 'stylesChanged', + this.props.style as Partial, + ); this._isUpdateQueued = false; @@ -927,6 +1571,18 @@ export class LightningViewElement< private _onLayout = (dimensions: Rect) => { this._hasLayout = true; + + // First layout resolved — reveal a withheld node at its now-correct + // geometry. See {@link withholdPaintUntilLayout}. + if (this._paintWithheld) { + this._paintWithheld = false; + + if (this.node.alpha !== this._withheldAlpha) { + this.node.alpha = this._withheldAlpha; + this.recalculateVisibility(); + } + } + this.props.onLayout?.(dimensions); }; @@ -943,7 +1599,9 @@ export class LightningViewElement< return animation; } - private _getShaderFromStyle(style: TStyleProps | undefined | null): ShaderDef | undefined { + private _getShaderFromStyle( + style: TStyleProps | undefined | null, + ): ShaderDef | undefined { if (!style) { return; } @@ -952,8 +1610,16 @@ export class LightningViewElement< let type: ShaderDef['type'] | undefined; let hasRounded = false; - const { border, borderColor, borderTop, borderLeft, borderRight, borderBottom, borderRadius } = - style; + const { + border, + borderColor, + borderTop, + borderLeft, + borderRight, + borderBottom, + borderRadius, + linearGradient, + } = style; if (borderRadius) { type = 'Rounded'; @@ -961,7 +1627,14 @@ export class LightningViewElement< hasRounded = true; } - if (border || borderColor || borderTop || borderLeft || borderRight || borderBottom) { + if ( + border || + borderColor || + borderTop || + borderLeft || + borderRight || + borderBottom + ) { if (type && type === 'Rounded') { type = 'RoundedWithBorder'; } else { @@ -998,7 +1671,33 @@ export class LightningViewElement< props[hasRounded ? 'border-color' : 'color'] = borderColor; } - return type ? { type, props } : undefined; + if (type) { + if (linearGradient) { + // Radius-only node: fold the radius into the gradient so it rounds its + // own corners (a node carries one shader, so a separate Rounded shader + // would drop the gradient). A real border still can't combine and wins. + if (type === 'Rounded') { + return { + type: 'LinearGradient', + props: { ...linearGradient, radius: props.radius }, + }; + } + + if (import.meta.env.DEV) { + console.warn( + `Warning: element ${this.id} sets both a background gradient and a border. A node can only carry one shader, so the border wins and the gradient is dropped.`, + ); + } + } + + return { type, props }; + } + + if (linearGradient) { + return { type: 'LinearGradient', props: linearGradient }; + } + + return undefined; } public _toLightningNodeProps( @@ -1073,8 +1772,18 @@ export class LightningViewElement< ); } + // Reanimated and imperative style.set pushes are marked partial: they carry + // only the changed keys, so keep the current shader unless the update itself + // names one. A full restyle (reconciler snapshot) isn't marked, so a dropped + // border falls through and clears the stale shader. + const isPartialStyle = + style != null && + (style as Record)[PARTIAL_STYLE] === true; const oldShader = this._shaderDef; - this._shaderDef = shader || styleShader; + this._shaderDef = + shader === undefined && isPartialStyle && !styleShader && oldShader + ? oldShader + : shader || styleShader; if (this._shaderDef?.props) { // if the shader is the same as the previous one, we don't need to recreate it @@ -1086,9 +1795,17 @@ export class LightningViewElement< this._shaderDef.props ) { this.animateShader(this._shaderDef.props); - } else if (this._shaderDef.type === oldShader?.type && this.shader.props) { + } else if ( + this._shaderDef.type === oldShader?.type && + this.shader.props + ) { for (const [key, value] of Object.entries(this._shaderDef.props)) { - if (this.shader.props[key]) { + // Gate on key existence, not truthiness: a prop whose current value + // is falsy (e.g. a transparent `border-color` of 0) must still be + // updatable — otherwise toggling a focus-ring border from + // transparent to a visible color on an already-mounted node is + // silently dropped and the ring never appears. + if (key in this.shader.props) { this.shader.props[key] = value; } } @@ -1098,6 +1815,12 @@ export class LightningViewElement< this._shaderDef.props, ); } + } else if (oldShader) { + // The node had a style/explicit shader (e.g. a focus-ring border) and now + // has none — clear it so the previous shader stops painting. Setting the + // node's shader to null resets it to the stage's default shader. Without + // this a removed border would linger on an already-mounted node. + (finalStyle as { shader: INode['shader'] | null }).shader = null; } if (texture && texture !== this._textureDef) { @@ -1105,9 +1828,38 @@ export class LightningViewElement< finalStyle.texture = createTexture(this._renderer, texture); } + // borderRadius + clipping (overflow: hidden) = rounded clipping, like the + // other RN platforms: the renderer stencil-clips children to clipRadius. + if (LightningViewElement.roundedClippingEnabled) { + const shaderType = this._shaderDef?.type; + // At mount the style proxy doesn't exist yet; the passed style is full. + const effectiveClipping = initial + ? style?.clipping + : (style?.clipping ?? this.style.clipping); + const radius = + effectiveClipping === true && + (shaderType === 'Rounded' || shaderType === 'RoundedWithBorder') + ? this._shaderDef?.props?.radius + : 0; + // The stencil takes one radius; a per-corner array clips to the largest. + const clipRadius = + (Array.isArray(radius) ? Math.max(...radius) : (radius as number)) || + 0; + + if ( + initial ? clipRadius > 0 : (this.node.clipRadius ?? 0) !== clipRadius + ) { + finalStyle.clipRadius = clipRadius; + } + } + const finalProps = Object.assign(otherProps, finalStyle); - if (initial === true && this.isImageElement === false && finalProps.color === undefined) { + if ( + initial === true && + this.isImageElement === false && + finalProps.color === undefined + ) { // set default color to 0 for all elements except image elements finalProps.color = 0; } @@ -1136,11 +1888,12 @@ export class LightningViewElement< target[key] = value; - this.setProps({ - style: { - [key]: value, - }, - } as Partial); + // Single-key imperative set: mark it partial so shader/flex processors + // keep the props this push omits. + const style = { [key]: value } as Partial; + (style as Record)[PARTIAL_STYLE] = true; + + this.setProps({ style } as Partial); return true; }, diff --git a/packages/react-lightning/src/element/partialStyle.ts b/packages/react-lightning/src/element/partialStyle.ts new file mode 100644 index 00000000..28abe189 --- /dev/null +++ b/packages/react-lightning/src/element/partialStyle.ts @@ -0,0 +1,7 @@ +/** + * Marks a style object as a partial update (e.g. an animated style pushed by + * the reanimated plugin). Style processors that treat a plain style prop as a + * full snapshot (resetting anything missing from it) must skip that reset for + * marked styles: the object only carries the keys that changed. + */ +export const PARTIAL_STYLE: unique symbol = Symbol.for('@plextv/partial-style'); diff --git a/packages/react-lightning/src/focus/FocusKeyManager.ts b/packages/react-lightning/src/focus/FocusKeyManager.ts index 429218da..c251894c 100644 --- a/packages/react-lightning/src/focus/FocusKeyManager.ts +++ b/packages/react-lightning/src/focus/FocusKeyManager.ts @@ -8,7 +8,15 @@ import type { FocusManager, FocusNode } from './FocusManager'; function* childElements(children: FocusNode[]): Iterable { for (let i = 0; i < children.length; i++) { // oxlint-disable-next-line typescript/no-non-null-assertion -- bounds-checked loop - yield children[i]!.element; + const child = children[i]!; + + // A focus group with no focusable descendant only wraps non-interactive + // content (a list header); skip it so nav lands on a real target. + if (child.element.isFocusGroup && !child.hasFocusableChildren) { + continue; + } + + yield child.element; } } diff --git a/packages/react-lightning/src/focus/FocusManager.spec.ts b/packages/react-lightning/src/focus/FocusManager.spec.ts index f27c8b09..63551b2f 100644 --- a/packages/react-lightning/src/focus/FocusManager.spec.ts +++ b/packages/react-lightning/src/focus/FocusManager.spec.ts @@ -367,6 +367,141 @@ describe('FocusManager', () => { }); }); + describe('focus-when-ready', () => { + it('fulfills a focus() request once the target element registers', () => { + const root = createMockElement(1, 'root'); + const a = createMockElement(2, 'a'); + const target = createMockElement(3, 'target'); + + focusManager.addElement(root, null); + focusManager.addElement(a, root, { autoFocus: true }); + expect(focusManager.focusPath).toEqual([root, a]); + + // Target hasn't mounted/registered yet — the request must not be dropped. + focusManager.focus(target); + expect(target.focused).toBe(false); + + // Target registers a moment later; the queued request now resolves. + focusManager.addElement(target, root); + expect(target.focused).toBe(true); + expect(focusManager.focusPath).toEqual([root, target]); + }); + + it('cancels a pending focus when the target is removed', () => { + const root = createMockElement(1, 'root'); + const a = createMockElement(2, 'a'); + const target = createMockElement(3, 'target'); + + focusManager.addElement(root, null); + focusManager.addElement(a, root, { autoFocus: true }); + + focusManager.focus(target); + focusManager.removeElement(target); + + // Re-registering must not retroactively fulfill the cancelled request. + focusManager.addElement(target, root); + expect(target.focused).toBe(false); + expect(focusManager.focusPath).toEqual([root, a]); + }); + + it('lets a later explicit focus supersede a pending request', () => { + const root = createMockElement(1, 'root'); + const a = createMockElement(2, 'a'); + const target = createMockElement(3, 'target'); + + focusManager.addElement(root, null); + focusManager.addElement(a, root, { autoFocus: true }); + + focusManager.focus(target); // queued (not registered) + focusManager.focus(a); // registered — supersedes the pending request + + expect(a.focused).toBe(true); + + focusManager.addElement(target, root); + expect(target.focused).toBe(false); + expect(focusManager.focusPath).toEqual([root, a]); + }); + }); + + describe('arrival-not-mount autoFocus', () => { + it('does not let a later-mounting autoFocus child steal committed focus', () => { + const root = createMockElement(1, 'root'); + const content = createMockElement(2, 'content'); + const nav = createMockElement(3, 'nav'); + + focusManager.addElement(content, root); + focusManager.addElement(root, null); + + // Focus is explicitly committed to content. + focusManager.focus(content); + expect(focusManager.focusPath).toEqual([root, content]); + + // A nav bar mounts a frame later with autoFocus — native semantics say it + // only forwards focus on arrival, so it must not steal it on mount. + focusManager.addElement(nav, root, { autoFocus: true }); + expect(content.focused).toBe(true); + expect(focusManager.focusPath).toEqual([root, content]); + }); + + it('still lets autoFocus upgrade a mount-default preferred child', () => { + const root = createMockElement(1, 'root'); + const a = createMockElement(2, 'a'); + const b = createMockElement(3, 'b'); + + focusManager.addElement(root, null); + // `a` becomes the preferred child only as a mount default (no explicit + // focus), so a later autoFocus child is still allowed to upgrade it. + focusManager.addElement(a, root); + focusManager.addElement(b, root, { autoFocus: true }); + + expect(focusManager.focusPath).toEqual([root, b]); + expect(b.focused).toBe(true); + }); + }); + + describe('destinations on arrival', () => { + it('forwards to a destination on first arrival, then remembers the child', () => { + const root = createMockElement(1, 'root'); + const group = createMockElement(2, 'group'); + const child1 = createMockElement(3, 'child1'); + const child2 = createMockElement(4, 'child2'); + + focusManager.addElement(root, null); + focusManager.addElement(group, root, { destinations: [child2] }); + focusManager.addElement(child1, group); + focusManager.addElement(child2, group); + + // First arrival forwards to the declared destination (child2), not the + // default first child (child1). + focusManager.focus(group); + expect(focusManager.focusPath).toEqual([root, group, child2]); + + // Move focus to child1, then re-enter the group: it now remembers the + // last-focused child instead of redirecting again. + focusManager.focus(child1); + expect(focusManager.focusPath).toEqual([root, group, child1]); + + focusManager.focus(group); + expect(focusManager.focusPath).toEqual([root, group, child1]); + }); + + it('always redirects with focusRedirect, every visit', () => { + const root = createMockElement(1, 'root'); + const real = createMockElement(2, 'real'); + const guide = createMockElement(3, 'guide'); + + focusManager.addElement(root, null); + focusManager.addElement(real, root); + focusManager.addElement(guide, root, { + focusRedirect: true, + destinations: [real], + }); + + focusManager.focus(guide); + expect(focusManager.focusPath).toEqual([root, real]); + }); + }); + describe('Layer Management (Modal Support)', () => { it('should create a new layer when pushLayer is called', () => { const mainElement = createMockElement(1, 'main'); @@ -623,4 +758,62 @@ describe('FocusManager', () => { expect(focusManager.focusPath).toEqual([modalParent]); }); }); + + describe('non-interactive focus groups', () => { + it('does not focus an empty focus group', () => { + const group = createMockElement(1, 'emptyGroup'); + group.isFocusGroup = true; + + focusManager.addElement(group, null, { autoFocus: true }); + + expect(group.focused).toBe(false); + expect(focusManager.focusPath).toEqual([]); + }); + + it('skips an empty focus group and focuses a real sibling', () => { + const group = createMockElement(1, 'emptyGroup'); + group.isFocusGroup = true; + const leaf = createMockElement(2, 'leaf'); + + focusManager.addElement(group, null, { autoFocus: false }); + focusManager.addElement(leaf, null, { autoFocus: false }); + + expect(focusManager.focusPath).toEqual([leaf]); + }); + + it('focuses a group once it gains a focusable child', () => { + const group = createMockElement(1, 'group'); + group.isFocusGroup = true; + const child = createMockElement(2, 'child'); + + focusManager.addElement(group, null, { autoFocus: false }); + expect(focusManager.focusPath).toEqual([]); + + focusManager.addElement(child, group, { autoFocus: false }); + expect(focusManager.focusPath).toEqual([group, child]); + }); + + it('moves focus off a group when its last focusable child is removed', () => { + const group = createMockElement(1, 'group'); + group.isFocusGroup = true; + const child = createMockElement(2, 'child'); + const sibling = createMockElement(3, 'sibling'); + + focusManager.addElement(group, null, { autoFocus: false }); + focusManager.addElement(child, group, { autoFocus: false }); + focusManager.addElement(sibling, null, { autoFocus: false }); + expect(focusManager.focusPath).toEqual([group, child]); + + focusManager.removeElement(child); + expect(focusManager.focusPath).toEqual([sibling]); + }); + + it('still focuses a normal leaf element that has no children', () => { + const leaf = createMockElement(1, 'leaf'); + + focusManager.addElement(leaf, null, { autoFocus: true }); + + expect(focusManager.focusPath).toEqual([leaf]); + }); + }); }); diff --git a/packages/react-lightning/src/focus/FocusManager.ts b/packages/react-lightning/src/focus/FocusManager.ts index 1502ba1c..c04cb470 100644 --- a/packages/react-lightning/src/focus/FocusManager.ts +++ b/packages/react-lightning/src/focus/FocusManager.ts @@ -9,6 +9,14 @@ type RootNode = { children: FocusNode[]; focusedElement: FocusNode | null; hasFocusableChildren: boolean; + /** + * True once focus has been explicitly committed into this node's subtree via + * `focus()`/spatial navigation (as opposed to a mount-time default). While + * committed, a later-mounting `autoFocus` child must not steal live focus on + * registration — matching native `TVFocusGuideView`, which forwards focus on + * arrival, not on mount. + */ + focusCommitted: boolean; }; export type FocusNode = Omit, 'element'> & { @@ -73,6 +81,13 @@ export class FocusManager< private _childFocusEventHandlers: Map void) | undefined> = new Map(); private _focusStack: FocusLayer[] = []; private _eventEmitter = new EventEmitter>(); + /** + * A focus request whose target was not yet registered (or not yet focusable) + * when `focus()` was called. Fulfilled the moment the element registers or + * becomes focusable, so callers don't have to poll across frames waiting for + * a node to mount/scroll into view. Last request wins. + */ + private _pendingFocus: T | null = null; public get activeLayer(): FocusLayer { if (this._focusStack.length === 0) { @@ -94,6 +109,7 @@ export class FocusManager< children: [], focusedElement: null, hasFocusableChildren: false, + focusCommitted: false, }, elements: new Map(), focusPath: [], @@ -225,15 +241,24 @@ export class FocusManager< this._checkFocusableChildren(parentNode); - if ( - child.focusable && - !hasExternalRedirect(childNode) && - (!parentNode.focusedElement || (!parentNode.focusedElement.autoFocus && autoFocus)) - ) { - parentNode.focusedElement = childNode; + if (this._isEffectivelyFocusable(childNode) && !hasExternalRedirect(childNode)) { + if (!parentNode.focusedElement) { + // No preferred child yet — take the slot regardless of autoFocus. + parentNode.focusedElement = childNode; + } else if (autoFocus && !parentNode.focusedElement.autoFocus && !parentNode.focusCommitted) { + // An autoFocus child upgrades a non-autoFocus preferred child only + // while focus hasn't been explicitly committed here. Once committed, + // a later-mounting autoFocus child must not steal live focus (it would + // diverge from native TVFocusGuideView, which forwards on arrival). + parentNode.focusedElement = childNode; + } } this._recalculateFocusPath(); + + // If a focus request was waiting on this element to register, fulfill it + // now that it's in the tree (and possibly focusable). + this._tryFulfillPendingFocus(child); } private _forAllNodes(element: T, callback: (node: FocusNode) => void): void { @@ -249,6 +274,10 @@ export class FocusManager< } public removeElement(element: T): void { + if (this._pendingFocus === element) { + this._pendingFocus = null; + } + this._forAllNodes(element, (node) => { this._removeNode(node, true); }); @@ -328,6 +357,10 @@ export class FocusManager< } public pushLayer(): void { + // A pending focus targets the layer it was requested in; drop it on a + // layer change so it can't fulfill against the wrong layer. + this._pendingFocus = null; + // Store the current layer before creating new one const previousLayer = this.activeLayer; @@ -349,6 +382,7 @@ export class FocusManager< children: [], focusedElement: null, hasFocusableChildren: false, + focusCommitted: false, }, elements: new Map(), focusPath: [], @@ -367,6 +401,10 @@ export class FocusManager< return; } + // A pending focus targets the layer it was requested in; drop it on a + // layer change so it can't fulfill against the wrong layer. + this._pendingFocus = null; + // Get current layer info before popping const currentLayer = this.activeLayer; @@ -411,13 +449,36 @@ export class FocusManager< public focus(element: T): void { const node = this.activeLayer.elements.get(element); - if (!node) { + // Not registered yet, or registered but not focusable yet (e.g. just + // mounted / scrolled into view, dimensions not measured). Queue the + // request instead of dropping it; it resolves once the element is ready. + if (!node || !element.focusable) { + this._pendingFocus = element; + return; } + this._pendingFocus = null; this._focusNode(node); } + /** + * Fulfill a queued {@link focus} request for `element` if it is now + * registered and focusable. No-op otherwise (it stays queued). + */ + private _tryFulfillPendingFocus(element: T): void { + if (this._pendingFocus !== element) { + return; + } + + const node = this.activeLayer.elements.get(element); + + if (node && element.focusable && !hasExternalRedirect(node)) { + this._pendingFocus = null; + this._focusNode(node); + } + } + // Print out the whole focus tree public toString(): string { const printNode = (node: FocusNode | RootNode, depth = 0): string => { @@ -503,6 +564,7 @@ export class FocusManager< traps, hasFocusableChildren: false, allowOffscreen, + focusCommitted: false, }; this.activeLayer.elements.set(element, node); @@ -538,6 +600,12 @@ export class FocusManager< this._checkFocusableChildren(currentNode.parent); this._recalculateFocusPath(); + + // A queued focus request may have been waiting on this element to + // become focusable. + if (isFocusable) { + this._tryFulfillPendingFocus(element); + } }), element.on('focusChanged', (_, isFocused) => { if (isFocused && !element.focused) { @@ -567,47 +635,82 @@ export class FocusManager< } } - private _focusNode(childNode: FocusNode, visitedRedirects?: Set) { - let currParent = childNode.parent; - let currChild: FocusNode | RootNode = childNode; - const elements = this.activeLayer.elements; + /** + * Forward focus to the first focusable destination of `node`, recursing + * through any further redirects. Returns true when focus was redirected (or + * the redirect was aborted on a missing node / cycle) and the caller should + * stop; false when there was no focusable destination and the caller should + * focus `node` normally. + */ + private _redirectToDestination(node: FocusNode, visitedRedirects?: Set): boolean { + // TODO: Probably something smarter here to decide which destination to focus + const destination = node.destinations?.find((child) => child?.focusable); - if (currChild.children.length && !currChild.focusedElement) { - currChild.focusedElement = this._findNextBestFocus(currChild); + if (!destination) { + return false; } - while (currChild && !isRootNode(currChild) && currParent) { - if (currChild.focusRedirect && currChild.destinations) { - // TODO: Probably something smarter here to decide which destination to focus - const destination = currChild.destinations?.find((child) => child?.focusable); + const focusNode = this.activeLayer.elements.get(destination); - if (destination) { - const focusNode = elements.get(destination); + if (!focusNode) { + console.warn('FocusManager: No focus node found for destination', destination); - if (!focusNode) { - console.warn('FocusManager: No focus node found for destination', destination); + return true; + } - return; - } + // Detect redirect cycles + const visited = visitedRedirects ?? new Set(); - // Detect redirect cycles - const visited = visitedRedirects ?? new Set(); + if (visited.has(destination)) { + console.warn('FocusManager: Focus redirect cycle detected, aborting'); - if (visited.has(destination)) { - console.warn('FocusManager: Focus redirect cycle detected, aborting'); + return true; + } - return; - } + visited.add(destination); - visited.add(destination); + this._focusNode(focusNode, visited); - this._focusNode(focusNode, visited); + return true; + } - return; - } + private _focusNode(childNode: FocusNode, visitedRedirects?: Set) { + // On arrival, forward to a declared destination. With focusRedirect this + // happens on every visit (a permanent redirect); without it, only on the + // first visit (no remembered child yet) — matching native + // TVFocusGuideView, which forwards focus on arrival then remembers the + // last-focused child for subsequent visits. + if ( + childNode.destinations && + (childNode.focusRedirect || !childNode.focusCommitted) && + this._redirectToDestination(childNode, visitedRedirects) + ) { + return; + } + + let currParent = childNode.parent; + let currChild: FocusNode | RootNode = childNode; + + if (currChild.children.length && !currChild.focusedElement) { + currChild.focusedElement = this._findNextBestFocus(currChild); + } + + // Focus has now explicitly arrived at this node, so mark its subtree as + // committed: a later-mounting autoFocus sibling must not steal it on + // registration (see addElement / focusCommitted). + childNode.focusCommitted = true; + + while (currChild && !isRootNode(currChild) && currParent) { + if ( + currChild.focusRedirect && + currChild.destinations && + this._redirectToDestination(currChild, visitedRedirects) + ) { + return; } currParent.focusedElement = currChild as FocusNode; + currParent.focusCommitted = true; currChild = currParent; currParent = 'parent' in currChild ? currChild.parent : this.activeLayer.root; } @@ -647,6 +750,12 @@ export class FocusManager< this.activeLayer.elements.delete(node.element); if (isTopMostParentNode) { + // Removing a child can empty a focus-group parent; recompute so its + // effective focusability and the ancestor chain update. + if (!isRootNode(node.parent) && node.parent.element.isFocusGroup) { + this._checkFocusableChildren(node.parent); + } + this._recalculateFocusPath(); } @@ -657,16 +766,21 @@ export class FocusManager< this._removeEventListeners(node); } + // A focus group only delegates, so it's a target only with a focusable + // descendant. Leaves (Pressable, focusable View) always are. + private _isEffectivelyFocusable(node: FocusNode): boolean { + if (!node.element.focusable) { + return false; + } + + return !node.element.isFocusGroup || node.hasFocusableChildren; + } + private _checkFocusableChildren(parentNode: FocusNode | RootNode) { + const previous = parentNode.hasFocusableChildren; const children = parentNode.children; const childrenLength = children.length; - if (childrenLength === 0) { - parentNode.hasFocusableChildren = false; - - return; - } - const leafNodes = new Set(); let hasFocusableChildren = false; @@ -674,7 +788,7 @@ export class FocusManager< // oxlint-disable-next-line typescript/no-non-null-assertion -- Already asserted that child exists const child = children[i]!; - if (child.element.focusable) { + if (this._isEffectivelyFocusable(child)) { hasFocusableChildren = true; } @@ -685,24 +799,45 @@ export class FocusManager< parentNode.hasFocusableChildren = hasFocusableChildren; - // Early return if no leaf nodes to check - if (leafNodes.size === 0) { - return; - } - // Check each child for leaf node ancestry and update focusability - for (let i = 0; i < childrenLength; i++) { - // oxlint-disable-next-line typescript/no-non-null-assertion -- Already asserted that child exists - const child = children[i]!; + if (leafNodes.size > 0) { + for (let i = 0; i < childrenLength; i++) { + // oxlint-disable-next-line typescript/no-non-null-assertion -- Already asserted that child exists + const child = children[i]!; - if (this._hasLeafParent(child.element, leafNodes, parentNode.element)) { - child.element.focusable = false; + if (this._hasLeafParent(child.element, leafNodes, parentNode.element)) { + child.element.focusable = false; - if (parentNode.focusedElement === child) { - parentNode.focusedElement = this._findNextBestFocus(parentNode, child); + if (parentNode.focusedElement === child) { + parentNode.focusedElement = this._findNextBestFocus(parentNode, child); + } } } } + + // A group's effective focusability tracks hasFocusableChildren, so a flip + // here has to refresh the ancestor chain (non-group parents don't). + if ( + previous !== hasFocusableChildren && + !isRootNode(parentNode) && + parentNode.element.isFocusGroup + ) { + this._propagateFocusableChange(parentNode); + } + } + + private _propagateFocusableChange(node: FocusNode) { + const parent = node.parent; + + if (this._isEffectivelyFocusable(node)) { + if (!parent.focusedElement && !hasExternalRedirect(node)) { + parent.focusedElement = node; + } + } else if (parent.focusedElement === node) { + parent.focusedElement = this._findNextBestFocus(parent, node); + } + + this._checkFocusableChildren(parent); } private _hasLeafParent(element: T, leafNodes: Set, parentNode: T | null): boolean { @@ -741,7 +876,8 @@ export class FocusManager< const newChild = parent.children[i]; if ( - newChild?.element.focusable && + newChild && + this._isEffectivelyFocusable(newChild) && !hasExternalRedirect(newChild) && newChild !== relativeNode ) { diff --git a/packages/react-lightning/src/index.ts b/packages/react-lightning/src/index.ts index 4f51de4a..e93b60ed 100644 --- a/packages/react-lightning/src/index.ts +++ b/packages/react-lightning/src/index.ts @@ -5,6 +5,7 @@ export { createLightningElement } from './element/createLightningElement'; export { LightningImageElement } from './element/LightningImageElement'; export { LightningTextElement } from './element/LightningTextElement'; export { LightningViewElement } from './element/LightningViewElement'; +export { PARTIAL_STYLE } from './element/partialStyle'; export { FocusGroup, type FocusGroupProps } from './focus/FocusGroup'; export { FocusGroupContext } from './focus/FocusGroupContext'; export { FocusManager } from './focus/FocusManager'; @@ -17,3 +18,4 @@ export { createRoot, type LightningRoot, LightningRootContext, type RenderOption export type { Plugin } from './render/Plugin'; export * from './types'; export { simpleDiff } from './utils/simpleDiff'; +export { CanvasRoot } from './components/Canvas/CanvasRoot'; diff --git a/packages/react-lightning/src/input/KeyPressHandler.tsx b/packages/react-lightning/src/input/KeyPressHandler.tsx index d5b45cb6..c2eeef5e 100644 --- a/packages/react-lightning/src/input/KeyPressHandler.tsx +++ b/packages/react-lightning/src/input/KeyPressHandler.tsx @@ -3,9 +3,11 @@ import { useContext, useEffect, useRef } from 'react'; import { useFocusManager } from '../focus/useFocusManager'; import { bubbleEvent } from './bubbleEvent'; +import { hasModifierKey } from './hasModifierKey'; import type { KeyMap } from './KeyMapContext'; import { KeyMapContext } from './KeyMapContext'; import { Keys } from './Keys'; +import { normalizeKeyEvent } from './normalizeKeyEvent'; const LONG_PRESS_THRESHOLD = 500; @@ -16,51 +18,48 @@ export const KeyPressHandler: FC<{ children: ReactNode }> = ({ children }) => { const createKeyHandler = (handler: 'onKeyDown' | 'onKeyUp', keyMap: KeyMap) => { return (event: KeyboardEvent) => { - if (event.repeat) { + const element = focusManager.focusPath.at(-1); + + if (!element || !(event instanceof KeyboardEvent)) { return; } - const element = focusManager.focusPath.at(-1); - - if (!element) { + // Modifier combos (Cmd+Opt+I, etc.) are host shortcuts, not remote input. + // Let them through so devtools and browser/Storybook shortcuts still work. + if (hasModifierKey(event)) { return; } - if (event instanceof KeyboardEvent) { - const remoteKey = keyMap[event.keyCode] ?? Keys.Unknown; - - // Build the event object once and reuse for all bubbleEvent calls - const keyEvent = { - keyCode: event.keyCode, - key: event.key, - code: event.code, - remoteKey, - repeat: event.repeat, - target: element, - currentTarget: element, - stopFocusHandling: false, - preventDefault: event.preventDefault, - }; - - if (handler === 'onKeyDown') { + // Build the normalized event once and reuse for all bubbleEvent calls. + const keyEvent = normalizeKeyEvent(event, keyMap, element); + const { remoteKey } = keyEvent; + + if (handler === 'onKeyDown') { + // Stamp the press time only on the initial press — not on the OS + // auto-repeats that follow while a key is held. Otherwise the + // long-press duration measured at key-up would reset to ~0 on every + // repeat and a held key would never register as a long press. The + // repeats still bubble below, so held directional keys keep navigating + // and handlers can read `repeat` to drive held-key behavior. + if (!event.repeat) { keyDownTime.current = event.timeStamp; - } else if (handler === 'onKeyUp') { - const duration = event.timeStamp - keyDownTime.current; + } + } else if (handler === 'onKeyUp') { + const duration = event.timeStamp - keyDownTime.current; - keyDownTime.current = 0; + keyDownTime.current = 0; - bubbleEvent(duration > LONG_PRESS_THRESHOLD ? 'onLongPress' : 'onKeyPress', keyEvent); + bubbleEvent(duration > LONG_PRESS_THRESHOLD ? 'onLongPress' : 'onKeyPress', keyEvent); - // Reset stopFocusHandling for the next bubbleEvent call - keyEvent.stopFocusHandling = false; - } + // Reset stopFocusHandling for the next bubbleEvent call + keyEvent.stopFocusHandling = false; + } - bubbleEvent(handler, keyEvent); + bubbleEvent(handler, keyEvent); - if (remoteKey !== Keys.Unknown) { - event.stopPropagation(); - event.preventDefault(); - } + if (remoteKey !== Keys.Unknown) { + event.stopPropagation(); + event.preventDefault(); } }; }; diff --git a/packages/react-lightning/src/input/bubbleEvent.spec.ts b/packages/react-lightning/src/input/bubbleEvent.spec.ts new file mode 100644 index 00000000..c07dd92a --- /dev/null +++ b/packages/react-lightning/src/input/bubbleEvent.spec.ts @@ -0,0 +1,76 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { KeyEvent, LightningElement } from '../types'; +import { bubbleEvent } from './bubbleEvent'; +import { Keys } from './Keys'; + +type Handlers = Partial< + Record< + 'onKeyDown' | 'onKeyUp' | 'onKeyPress' | 'onLongPress', + (event: KeyEvent) => boolean | undefined + > +>; + +function makeElement(props: Handlers, parent: LightningElement | null = null): LightningElement { + return { props, parent } as unknown as LightningElement; +} + +function keyEvent(target: LightningElement): KeyEvent { + return { + key: 'ArrowRight', + code: 'ArrowRight', + keyCode: 39, + remoteKey: Keys.Right, + repeat: false, + target, + currentTarget: target, + stopFocusHandling: false, + preventDefault: vi.fn(), + }; +} + +describe('bubbleEvent', () => { + it('bubbles from target up through the parent chain', () => { + const order: string[] = []; + const root = makeElement({ onKeyDown: () => void order.push('root') }); + const mid = makeElement({ onKeyDown: () => void order.push('mid') }, root); + const leaf = makeElement({ onKeyDown: () => void order.push('leaf') }, mid); + + bubbleEvent('onKeyDown', keyEvent(leaf)); + + expect(order).toEqual(['leaf', 'mid', 'root']); + }); + + it('stops bubbling when a handler returns false', () => { + const rootHandler = vi.fn(); + const root = makeElement({ onKeyDown: rootHandler }); + const leaf = makeElement({ onKeyDown: () => false }, root); + + bubbleEvent('onKeyDown', keyEvent(leaf)); + + expect(rootHandler).not.toHaveBeenCalled(); + }); + + it('updates currentTarget to the element handling the event', () => { + const seen: Array = []; + const root = makeElement({ + onKeyDown: (e) => void seen.push(e.currentTarget), + }); + const leaf = makeElement({ onKeyDown: (e) => void seen.push(e.currentTarget) }, root); + + bubbleEvent('onKeyDown', keyEvent(leaf)); + + expect(seen).toEqual([leaf, root]); + }); + + it('skips elements without a matching handler and keeps bubbling', () => { + const rootHandler = vi.fn(); + const root = makeElement({ onKeyDown: rootHandler }); + const mid = makeElement({}, root); + const leaf = makeElement({}, mid); + + bubbleEvent('onKeyDown', keyEvent(leaf)); + + expect(rootHandler).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/react-lightning/src/input/bubbleEvent.tsx b/packages/react-lightning/src/input/bubbleEvent.tsx index 57e4f37c..33526639 100644 --- a/packages/react-lightning/src/input/bubbleEvent.tsx +++ b/packages/react-lightning/src/input/bubbleEvent.tsx @@ -2,7 +2,7 @@ import type { KeyEvent, LightningElement } from '../types'; type BubbleEventFn = ( handler: 'onKeyUp' | 'onKeyDown' | 'onKeyPress' | 'onLongPress', - event: KeyEvent & { currentTarget: LightningElement }, + event: KeyEvent, ) => void; export const bubbleEvent: BubbleEventFn = (handler, event) => { let element: LightningElement | undefined | null = event.target; diff --git a/packages/react-lightning/src/input/hasModifierKey.spec.ts b/packages/react-lightning/src/input/hasModifierKey.spec.ts new file mode 100644 index 00000000..f9a1032c --- /dev/null +++ b/packages/react-lightning/src/input/hasModifierKey.spec.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; + +import { hasModifierKey, type ModifierKeyEvent } from './hasModifierKey'; + +function event(overrides: Partial = {}): ModifierKeyEvent { + return { metaKey: false, ctrlKey: false, altKey: false, ...overrides }; +} + +describe('hasModifierKey', () => { + it('is false when no modifier is held', () => { + expect(hasModifierKey(event())).toBe(false); + }); + + it('is true when meta, ctrl, or alt is held', () => { + expect(hasModifierKey(event({ metaKey: true }))).toBe(true); + expect(hasModifierKey(event({ ctrlKey: true }))).toBe(true); + expect(hasModifierKey(event({ altKey: true }))).toBe(true); + }); + + it('ignores shift so plain keycodes still map (shift does not form a host shortcut here)', () => { + expect(hasModifierKey(event({ shiftKey: true } as Partial))).toBe(false); + }); +}); diff --git a/packages/react-lightning/src/input/hasModifierKey.ts b/packages/react-lightning/src/input/hasModifierKey.ts new file mode 100644 index 00000000..e07b655f --- /dev/null +++ b/packages/react-lightning/src/input/hasModifierKey.ts @@ -0,0 +1,13 @@ +/** The modifier flags of a {@link KeyboardEvent} the key pipeline cares about. */ +export type ModifierKeyEvent = Pick; + +/** + * True when a Cmd/Ctrl/Alt modifier is held. A TV remote never sends modifiers, + * so these events are host shortcuts (devtools, select-all, Storybook keys) that + * the framework should let through rather than swallow. Shift is deliberately + * ignored: it doesn't form a host shortcut here and the keycode map is + * shift-independent. + */ +export function hasModifierKey(event: ModifierKeyEvent): boolean { + return event.metaKey || event.ctrlKey || event.altKey; +} diff --git a/packages/react-lightning/src/input/normalizeKeyEvent.spec.ts b/packages/react-lightning/src/input/normalizeKeyEvent.spec.ts new file mode 100644 index 00000000..e9176f0a --- /dev/null +++ b/packages/react-lightning/src/input/normalizeKeyEvent.spec.ts @@ -0,0 +1,63 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { LightningElement } from '../types'; +import type { KeyMap } from './KeyMapContext'; +import { Keys } from './Keys'; +import { normalizeKeyEvent, type RawKeyEvent } from './normalizeKeyEvent'; + +const element = { id: 1 } as unknown as LightningElement; + +const keyMap: KeyMap = { + 37: Keys.Left, + 38: Keys.Up, + 39: Keys.Right, + 40: Keys.Down, + 13: Keys.Enter, +}; + +function rawEvent(overrides: Partial = {}): RawKeyEvent { + return { + key: 'ArrowRight', + code: 'ArrowRight', + keyCode: 39, + repeat: false, + preventDefault: vi.fn(), + ...overrides, + }; +} + +describe('normalizeKeyEvent', () => { + it('maps the keyCode to a remoteKey via the key map', () => { + const result = normalizeKeyEvent(rawEvent({ keyCode: 38 }), keyMap, element); + + expect(result.remoteKey).toBe(Keys.Up); + }); + + it('falls back to Keys.Unknown for an unmapped keyCode', () => { + const result = normalizeKeyEvent(rawEvent({ keyCode: 999 }), keyMap, element); + + expect(result.remoteKey).toBe(Keys.Unknown); + }); + + it('preserves the held-key repeat flag', () => { + expect(normalizeKeyEvent(rawEvent({ repeat: true }), keyMap, element).repeat).toBe(true); + expect(normalizeKeyEvent(rawEvent({ repeat: false }), keyMap, element).repeat).toBe(false); + }); + + it('sets target and currentTarget to the focused element and defaults stopFocusHandling', () => { + const result = normalizeKeyEvent(rawEvent(), keyMap, element); + + expect(result.target).toBe(element); + expect(result.currentTarget).toBe(element); + expect(result.stopFocusHandling).toBe(false); + }); + + it('exposes a bound preventDefault that calls through without an illegal-invocation error', () => { + const preventDefault = vi.fn(); + const result = normalizeKeyEvent(rawEvent({ preventDefault }), keyMap, element); + + // A copied (unbound) DOM method would throw "Illegal invocation" here. + expect(() => result.preventDefault()).not.toThrow(); + expect(preventDefault).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/react-lightning/src/input/normalizeKeyEvent.ts b/packages/react-lightning/src/input/normalizeKeyEvent.ts new file mode 100644 index 00000000..beb7a8a5 --- /dev/null +++ b/packages/react-lightning/src/input/normalizeKeyEvent.ts @@ -0,0 +1,47 @@ +import type { LightningElement } from '../types'; +import type { KeyEvent } from '../types/KeyEvent'; +import type { KeyMap } from './KeyMapContext'; +import { Keys } from './Keys'; + +/** + * The slice of a DOM {@link KeyboardEvent} the key pipeline needs. Accepting a + * structural subset (rather than `KeyboardEvent`) lets synthesized remote events + * flow through the exact same normalization as real keyboard input. + */ +export type RawKeyEvent = Pick & { + preventDefault: () => void; +}; + +/** + * Builds a normalized {@link KeyEvent} from a raw DOM key event. + * + * Centralizes the three things the framework was previously doing + * inconsistently (or wrong) at the call site: + * + * - **keyCode → remoteKey** via the active {@link KeyMap}, falling back to + * {@link Keys.Unknown} so every event carries a defined `remoteKey`. + * - **held-key `repeat`** is preserved verbatim so downstream handlers can tell + * an OS auto-repeat from a fresh press (the basis for long-press / held-key + * navigation) instead of the repeats being dropped on the floor. + * - **a bound `preventDefault`** — the DOM method must run with its event as + * `this`, so copying the reference (`preventDefault: domEvent.preventDefault`) + * throws "Illegal invocation" the moment a handler calls it. Wrapping it in a + * closure keeps the normalized event self-contained and safe to invoke. + */ +export function normalizeKeyEvent( + domEvent: RawKeyEvent, + keyMap: KeyMap, + element: LightningElement, +): KeyEvent { + return { + key: domEvent.key, + code: domEvent.code, + keyCode: domEvent.keyCode, + remoteKey: keyMap[domEvent.keyCode] ?? Keys.Unknown, + repeat: domEvent.repeat, + target: element, + currentTarget: element, + stopFocusHandling: false, + preventDefault: () => domEvent.preventDefault(), + }; +} diff --git a/packages/react-lightning/src/mocks/createMockElement.ts b/packages/react-lightning/src/mocks/createMockElement.ts index ba12cd38..67ad1640 100644 --- a/packages/react-lightning/src/mocks/createMockElement.ts +++ b/packages/react-lightning/src/mocks/createMockElement.ts @@ -5,6 +5,8 @@ export class MockElement implements Focusable, EventNotifier { private _focusable = true; private _focused = false; + public isFocusGroup = false; + public constructor( public id = 0, public name = '', diff --git a/packages/react-lightning/src/render/createHostConfig.ts b/packages/react-lightning/src/render/createHostConfig.ts index 584dc47f..253bc478 100644 --- a/packages/react-lightning/src/render/createHostConfig.ts +++ b/packages/react-lightning/src/render/createHostConfig.ts @@ -12,6 +12,7 @@ import { type RendererNode, } from '../types'; import { simpleDiff } from '../utils/simpleDiff'; +import { isPrimitiveTextContent } from './isValidTextChild'; import { mapReactPropsToLightning } from './mapReactPropsToLightning'; import type { Plugin } from './Plugin'; @@ -164,8 +165,18 @@ export function createHostConfig(options?: LightningHostConfigOptions): Lightnin return instance as LightningElement; }, - shouldSetTextContent(type) { - return type === LightningElementType.Text; + shouldSetTextContent(type, props) { + // For text elements we normally take over their children as raw text + // content (the fast path — no child reconciliation). But that swallows + // children React still needs to render: a `` only + // becomes a translated, interpolated string once React renders it. So + // when the children aren't already a flat string, return false and let + // the reconciler render them; their text is folded back into the node by + // `LightningTextElement` as the string children are appended. + return ( + type === LightningElementType.Text && + isPrimitiveTextContent((props as LightningElementProps)?.children) + ); }, setCurrentUpdatePriority(newPriority: EventPriority): void { @@ -227,6 +238,14 @@ export function createHostConfig(options?: LightningHostConfigOptions): Lightnin commitTextUpdate(instance, oldText, newText) { if (instance.isTextElement && oldText !== newText) { instance.text = newText; + + // When this text instance is a child fragment of a parent text element + // (e.g. the string a `` resolved to), the parent owns + // the rendered text and must re-fold its children after the update. + const parent = instance.parent; + if (parent?.isTextElement) { + (parent as LightningTextElement).recomputeChildText(); + } } }, diff --git a/packages/react-lightning/src/render/index.tsx b/packages/react-lightning/src/render/index.tsx index e1d0d716..71546a34 100644 --- a/packages/react-lightning/src/render/index.tsx +++ b/packages/react-lightning/src/render/index.tsx @@ -1,3 +1,4 @@ +import { LightningViewElement } from '../element/LightningViewElement'; import { type CoreShaderType, RendererMain, @@ -57,6 +58,20 @@ export type RenderOptions = Omit< isPrimaryRenderer?: boolean; plugins?: Plugin[]; debug?: boolean; + /** + * borderRadius + overflow hidden clips children to the rounded rect (RN + * semantics) by rendering the subtree to a texture. Off by default: every + * such container costs a GPU framebuffer, and renderer RTT invalidation + * still has rough edges with late-mounting content. + */ + roundedClipping?: boolean; + /** + * Layout-only Views (no background, border, clip, alpha, transform or + * transition) skip renderer node creation; their children attach to the + * nearest materialized ancestor with positions folded in. Off by default + * while the optimization is validated per app. + */ + flattenLayoutViews?: boolean; shaders?: ShaderMap[]; textures?: Partial; }; @@ -93,6 +108,8 @@ const defaultOptions: Partial = { plugins: [], isPrimaryRenderer: true, debug: false, + roundedClipping: false, + flattenLayoutViews: false, }; export async function createRoot( @@ -104,6 +121,10 @@ export async function createRoot( ...(typeof options === 'function' ? options() : options), }; + LightningViewElement.roundedClippingEnabled = allOptions.roundedClipping === true; + LightningViewElement.flattenLayoutViewsEnabled = + allOptions.flattenLayoutViews === true; + // Don't use the lightning inspector, we have our own. const { fonts, useCanvas, includeCanvasFontRenderer, ...finalOptions } = allOptions; diff --git a/packages/react-lightning/src/render/isValidTextChild.test.ts b/packages/react-lightning/src/render/isValidTextChild.test.ts new file mode 100644 index 00000000..efbb7082 --- /dev/null +++ b/packages/react-lightning/src/render/isValidTextChild.test.ts @@ -0,0 +1,44 @@ +import { createElement } from 'react'; +import { describe, expect, it } from 'vitest'; + +import { isPrimitiveTextContent, isValidTextChild } from './isValidTextChild'; + +describe('isValidTextChild', () => { + it('accepts strings, numbers and booleans', () => { + expect(isValidTextChild('hello')).toBe(true); + expect(isValidTextChild(42)).toBe(true); + expect(isValidTextChild(true)).toBe(true); + }); + + it('rejects objects, arrays and elements', () => { + expect(isValidTextChild({})).toBe(false); + expect(isValidTextChild(['a'])).toBe(false); + expect(isValidTextChild(createElement('span'))).toBe(false); + }); +}); + +describe('isPrimitiveTextContent', () => { + it('treats empty/primitive children as flattenable here', () => { + expect(isPrimitiveTextContent(undefined)).toBe(true); + expect(isPrimitiveTextContent(null)).toBe(true); + expect(isPrimitiveTextContent('hello')).toBe(true); + expect(isPrimitiveTextContent(7)).toBe(true); + }); + + it('treats arrays of primitives (e.g. "Count: {n}") as flattenable', () => { + expect(isPrimitiveTextContent(['Count: ', 3])).toBe(true); + expect(isPrimitiveTextContent(['a', null, 'b'])).toBe(true); + }); + + it('defers element children to the reconciler', () => { + // A only becomes a translated, interpolated string once + // React renders it — the renderer must not try to flatten it itself. + const formattedMessage = createElement('FormattedMessage', { + defaultMessage: 'Hello {name}', + values: { name: 'world' }, + }); + + expect(isPrimitiveTextContent(formattedMessage)).toBe(false); + expect(isPrimitiveTextContent(['Hello ', formattedMessage])).toBe(false); + }); +}); diff --git a/packages/react-lightning/src/render/isValidTextChild.ts b/packages/react-lightning/src/render/isValidTextChild.ts index c2ccbcef..8a5e55e1 100644 --- a/packages/react-lightning/src/render/isValidTextChild.ts +++ b/packages/react-lightning/src/render/isValidTextChild.ts @@ -1,3 +1,20 @@ export function isValidTextChild(text: unknown): text is boolean | number | string { return typeof text === 'string' || typeof text === 'number' || typeof text === 'boolean'; } + +/** + * True when `children` can be flattened to a string here in the renderer + * (a primitive, an empty value, or an array of those). When this is false the + * children include something only React can resolve — a ``, + * a ternary returning an element, a fragment — so we must let the reconciler + * render them rather than guess at their text. See `shouldSetTextContent`. + */ +export function isPrimitiveTextContent(children: unknown): boolean { + if (children == null || isValidTextChild(children)) { + return true; + } + + return ( + Array.isArray(children) && children.every((child) => child == null || isValidTextChild(child)) + ); +} diff --git a/packages/react-lightning/src/render/mapReactPropsToLightning.test.ts b/packages/react-lightning/src/render/mapReactPropsToLightning.test.ts new file mode 100644 index 00000000..5b89139c --- /dev/null +++ b/packages/react-lightning/src/render/mapReactPropsToLightning.test.ts @@ -0,0 +1,34 @@ +import { createElement } from 'react'; +import { describe, expect, it } from 'vitest'; + +import { LightningElementType, type LightningTextElementProps } from '../types'; +import { mapReactPropsToLightning } from './mapReactPropsToLightning'; + +describe('mapReactPropsToLightning — text children', () => { + const mapText = (children: unknown) => + mapReactPropsToLightning(LightningElementType.Text, { + children, + } as LightningTextElementProps) as LightningTextElementProps; + + it('uses a primitive child as the text content', () => { + expect(mapText('hello').text).toBe('hello'); + expect(mapText(42).text).toBe('42'); + }); + + it('concatenates an array of primitive children', () => { + expect(mapText(['Count: ', 3]).text).toBe('Count: 3'); + }); + + it('does not derive text from element children', () => { + // These reach the renderer only when React could not resolve them to a + // string. Folding them in here is what produced untranslated / + // non-interpolated output before — the reconciler renders them instead and + // LightningTextElement folds the result back in. + const formattedMessage = createElement('FormattedMessage', { + defaultMessage: 'Hello {name}', + values: { name: 'world' }, + }); + + expect(mapText(formattedMessage).text).toBeUndefined(); + }); +}); diff --git a/packages/react-lightning/src/render/mapReactPropsToLightning.ts b/packages/react-lightning/src/render/mapReactPropsToLightning.ts index 37d7ff37..4aeec87e 100644 --- a/packages/react-lightning/src/render/mapReactPropsToLightning.ts +++ b/packages/react-lightning/src/render/mapReactPropsToLightning.ts @@ -5,16 +5,6 @@ import { } from '../types'; import { isValidTextChild } from './isValidTextChild'; -function isIntlObject(obj: unknown): obj is { props: { defaultMessage?: string } } { - return ( - typeof obj === 'object' && - obj !== null && - 'props' in obj && - !!obj.props && - 'defaultMessage' in (obj.props as { defaultMessage?: string }) - ); -} - /** * Converts React props to work with LightningElements */ @@ -33,7 +23,11 @@ export function mapReactPropsToLightning( for (prop in props) { switch (prop) { case 'children': - // If it's text, we don't actually use children as text + // Text takes its children as raw text content rather than as rendered + // child nodes — but only the primitive cases reach us here. Anything + // React must render (a ``, a ternary, a fragment) is + // routed through the reconciler by `shouldSetTextContent` and folded + // back in by `LightningTextElement`, so we never see it as a prop. if (type === LightningElementType.Text) { const textProps = mappedProps as LightningTextElementProps; const children = props[prop]; @@ -41,26 +35,15 @@ export function mapReactPropsToLightning( if (isValidTextChild(children)) { textProps.text = String(children); } else if (Array.isArray(children)) { - // Single-pass: validate and concatenate simultaneously let text = ''; - let allValid = true; for (let i = 0; i < children.length; i++) { if (isValidTextChild(children[i])) { text += String(children[i]); - } else { - allValid = false; - break; } } - if (allValid) { - textProps.text = text; - } - } else if (isIntlObject(children)) { - textProps.text = children.props.defaultMessage; - } else if (children) { - console.error('Unsupported child type found for text element'); + textProps.text = text; } } diff --git a/packages/react-lightning/src/types/KeyEvent.ts b/packages/react-lightning/src/types/KeyEvent.ts index 05179178..8cacf7eb 100644 --- a/packages/react-lightning/src/types/KeyEvent.ts +++ b/packages/react-lightning/src/types/KeyEvent.ts @@ -7,6 +7,7 @@ export type KeyEvent = { keyCode: number; remoteKey: Keys | Keys[]; target: LightningElement; + currentTarget: LightningElement; repeat: boolean; stopFocusHandling: boolean; diff --git a/packages/react-lightning/src/types/LightningElementEvents.ts b/packages/react-lightning/src/types/LightningElementEvents.ts index c90523b6..d70ac9fb 100644 --- a/packages/react-lightning/src/types/LightningElementEvents.ts +++ b/packages/react-lightning/src/types/LightningElementEvents.ts @@ -20,6 +20,8 @@ export interface LightningElementEvents extends FocusEvents { deferredDestroyComplete: () => void; childAdded: (child: LightningElement, index: number) => void; childRemoved: (child: LightningElement, index: number) => void; + /** Same-parent reorder: children[] order changed but the child's node/lifecycle didn't. */ + childMoved: (child: LightningElement, fromIndex: number, toIndex: number) => void; beforeRender: () => void; layout: (dimensions: Rect) => void; resized: (element: LightningElement, dimensions: { w: number; h: number }) => void; diff --git a/packages/react-lightning/src/types/Styles.ts b/packages/react-lightning/src/types/Styles.ts index 92e63714..835fe439 100644 --- a/packages/react-lightning/src/types/Styles.ts +++ b/packages/react-lightning/src/types/Styles.ts @@ -30,6 +30,14 @@ export interface LightningViewElementStyle extends Omit< */ borderRadius?: number | [number, number?, number?, number?]; + /** + * Parsed linear-gradient, applied as a LinearGradient shader. Set by + * plugin-css-transform from a css `background-image` / RN + * `experimental_backgroundImage` value. Colors are in Lightning 0xRRGGBBAA + * format, stops are 0..1, angle is radians. + */ + linearGradient?: { colors: number[]; stops: number[]; angle: number }; + /** Used as the initial dimensions for the element before yoga has calculated * where placement should actually go. This is to estimate where elements are * place on the screen so things like images don't all get loaded immediately diff --git a/packages/react-lightning/src/utils/findClosestElement.spec.ts b/packages/react-lightning/src/utils/findClosestElement.spec.ts index baa64540..3aa5b633 100644 --- a/packages/react-lightning/src/utils/findClosestElement.spec.ts +++ b/packages/react-lightning/src/utils/findClosestElement.spec.ts @@ -436,3 +436,29 @@ suite('getOverlap', () => { }); }); }); + +describe('perpendicular-axis clamping (beam semantics)', () => { + // EPG-shaped layout: moving down from an airing cell must land on the next + // row's wide airings group (which overlaps the source), not the small + // channel-header leaf sitting far to the left. Center-based cross-axis + // distance used to pick the header. Same shape sideways for the tall case. + describe('wide overlapping group beats a small off-axis leaf going down', () => { + const elements = createLayout(1920, 1080, [ + { x: 900, y: 700, w: 400, h: 88 }, // source: focused airing cell + { x: 431, y: 796, w: 104, h: 88 }, // next row's channel header + { x: 543, y: 796, w: 1376, h: 88 }, // next row's airings group + ]); + + runTestsOnElements(elements, [[1, Direction.Down, 3]]); + }); + + describe('tall overlapping group beats a small off-axis leaf going right', () => { + const elements = createLayout(1920, 1080, [ + { x: 100, y: 500, w: 200, h: 100 }, // source + { x: 400, y: 100, w: 100, h: 100 }, // small leaf far above + { x: 400, y: 150, w: 100, h: 800 }, // tall group overlapping the source row + ]); + + runTestsOnElements(elements, [[1, Direction.Right, 3]]); + }); +}); diff --git a/packages/react-lightning/src/utils/findClosestElement.ts b/packages/react-lightning/src/utils/findClosestElement.ts index 247af1da..067c6c0a 100644 --- a/packages/react-lightning/src/utils/findClosestElement.ts +++ b/packages/react-lightning/src/utils/findClosestElement.ts @@ -1,6 +1,10 @@ import { Direction } from '../focus/Direction'; import type { LightningElement } from '../types'; +function clampToSpan(value: number, start: number, size: number): number { + return Math.max(start, Math.min(value, start + size)); +} + type Dimensions = { w: number; h: number; @@ -33,9 +37,13 @@ function getDistance(direction: Direction, source: Dimensions, target: Dimension let targetX: number; let targetY: number; + // On the axis perpendicular to the direction, measure to the closest point + // of the target's span instead of its center (native beam semantics). A + // row-spanning group whose center sits far off-axis would otherwise lose to + // a small nearby leaf even when it overlaps the source dead-on. switch (direction) { case Direction.Up: - targetX = target.centerX; + targetX = clampToSpan(source.centerX, target.x, target.w); targetY = target.y + target.h; if (targetY > source.centerY) { @@ -45,7 +53,7 @@ function getDistance(direction: Direction, source: Dimensions, target: Dimension break; case Direction.Right: targetX = target.x; - targetY = target.centerY; + targetY = clampToSpan(source.centerY, target.y, target.h); if (targetX < source.centerX) { return null; @@ -53,7 +61,7 @@ function getDistance(direction: Direction, source: Dimensions, target: Dimension break; case Direction.Down: - targetX = target.centerX; + targetX = clampToSpan(source.centerX, target.x, target.w); targetY = target.y; if (targetY < source.centerY) { @@ -63,7 +71,7 @@ function getDistance(direction: Direction, source: Dimensions, target: Dimension break; case Direction.Left: targetX = target.x + target.w; - targetY = target.centerY; + targetY = clampToSpan(source.centerY, target.y, target.h); if (targetX > source.centerX) { return null; @@ -115,14 +123,14 @@ function getAlignment(direction: Direction, source: Dimensions, overlap: number) return bias * 5; } -function getDisplacement( - direction: Direction, - { w: w1, h: h1, centerX: cx1, centerY: cy1 }: Dimensions, - { centerX: cx2, centerY: cy2 }: Dimensions, -) { +function getDisplacement(direction: Direction, source: Dimensions, target: Dimensions) { const isHorizontal = direction & Direction.Horizontal; - const distance = isHorizontal ? cy2 - cy1 : cx2 - cx1; - const bias = isHorizontal ? h1 / 2 : w1 / 2; + // Same clamped-point rule as getDistance: an overlapping target has no + // perpendicular displacement, however wide or tall it is. + const distance = isHorizontal + ? clampToSpan(source.centerY, target.y, target.h) - source.centerY + : clampToSpan(source.centerX, target.x, target.w) - source.centerX; + const bias = isHorizontal ? source.h / 2 : source.w / 2; const weight = isHorizontal ? 30 : 2; return Math.abs((distance + bias) * weight); @@ -143,7 +151,7 @@ export function getOverlap( length = x1 + w1 > x2 && x1 + w1 < x2 + w2 ? x1 + w1 - x2 : 0; break; case Direction.Down: - length = y1 + w1 > y2 && y1 + w1 < y2 + h2 ? y1 + h1 - y2 : 0; + length = y1 + h1 > y2 && y1 + h1 < y2 + h2 ? y1 + h1 - y2 : 0; break; case Direction.Left: length = x1 > x2 && x1 < x2 + w2 ? x2 + w2 - x1 : 0; diff --git a/packages/react-native-lightning/src/exports/Image.tsx b/packages/react-native-lightning/src/exports/Image.tsx index 4fa9cfb6..59e31c3f 100644 --- a/packages/react-native-lightning/src/exports/Image.tsx +++ b/packages/react-native-lightning/src/exports/Image.tsx @@ -5,57 +5,108 @@ import type { Image as RNImage, ImageProps as RNImageProps, } from 'react-native'; - -import type { LightningElementStyle, LightningImageElement } from '@plextv/react-lightning'; - +import type { + LightningElementStyle, + LightningImageElement, +} from '@plextv/react-lightning'; +import { flattenStyles } from '@plextv/react-lightning-plugin-css-transform'; import { useImageLoadedHandler } from '../hooks/useImageLoadedHandler'; import { useLayoutHandler } from '../hooks/useLayoutHandler'; export type ImageProps = RNImageProps; -function isImageURISource(source: ImageSourcePropType): source is ImageURISource { +function isImageURISource( + source: ImageSourcePropType, +): source is ImageURISource { return !Array.isArray(source); } -export type Image = RNImage & LightningImageElement; +export type Image = LightningImageElement & RNImage; + +// Map RN `resizeMode` to the texture resizeMode so images keep aspect. Only +// cover/contain have equivalents; others fall back to the default (stretch). +function resolveResizeMode( + resizeMode: RNImageProps['resizeMode'], +): { type: 'contain' } | { type: 'cover' } | undefined { + if (resizeMode === 'cover') { + return { type: 'cover' }; + } + + if (resizeMode === 'contain') { + return { type: 'contain' }; + } + + return undefined; +} export const Image: ForwardRefExoticComponent = forwardRef< LightningImageElement, RNImageProps ->(({ onLoad, onLayout, width, height, src, source, style, ...otherProps }, ref) => { - const handleImageLayout = useLayoutHandler(onLayout); - const handleImageLoaded = useImageLoadedHandler(src as string, onLoad); +>( + ( + { + onLoad, + onLayout, + width, + height, + src, + source, + style, + resizeMode, + ...otherProps + }, + ref, + ) => { + const handleImageLayout = useLayoutHandler(onLayout); + const handleImageLoaded = useImageLoadedHandler(src as string, onLoad); - let finalSource: string | undefined; + let finalSource: string | undefined; - if (typeof source === 'object') { - if (!isImageURISource(source)) { - console.error('[Image] Lightning images only support ImageURISource as a source'); + if (typeof source === 'object') { + if (!isImageURISource(source)) { + console.error( + '[Image] Lightning images only support ImageURISource as a source', + ); + } else { + finalSource = source.uri; + } + } else if (typeof source === 'number') { + console.error('[Image] Lightning images do not support numeric sources'); + } else if (source || src) { + finalSource = source ?? src; } else { - finalSource = source.uri; + return null; } - } else if (typeof source === 'number') { - console.error('[Image] Lightning images do not support numeric sources'); - } else if (source || src) { - finalSource = source ?? src; - } else { - return null; - } - return ( - - ); -}); + const flattenedStyle = flattenStyles(style) as LightningElementStyle; + const resolvedResizeMode = resolveResizeMode(resizeMode); + + return ( + + ); + }, +); Image.displayName = 'Image'; diff --git a/packages/react-native-lightning/src/exports/Pressable.tsx b/packages/react-native-lightning/src/exports/Pressable.tsx index 5bcda766..c3ebee94 100644 --- a/packages/react-native-lightning/src/exports/Pressable.tsx +++ b/packages/react-native-lightning/src/exports/Pressable.tsx @@ -1,18 +1,22 @@ import type { ForwardRefExoticComponent, RefAttributes } from 'react'; import { useState } from 'react'; import type { PressableProps as RNPressableProps } from 'react-native'; - import type { KeyEvent } from '@plextv/react-lightning'; -import { focusable, Keys, type LightningViewElement } from '@plextv/react-lightning'; - +import { + Keys, + type LightningViewElement, + focusable, +} from '@plextv/react-lightning'; import { useBlurHandler, useFocusHandler } from '../hooks/useFocusHandler'; import { useLayoutHandler } from '../hooks/useLayoutHandler'; import { createGestureResponderEvent } from '../utils/createGestureResponderEvent'; import { View, type ViewProps } from './View'; -export type PressableProps = RNPressableProps & RefAttributes; +export type PressableProps = RefAttributes & RNPressableProps; -function useEnterKeyHandler(handler: (e: KeyEvent) => void): (e: KeyEvent) => boolean { +function useEnterKeyHandler( + handler: (e: KeyEvent) => void, +): (e: KeyEvent) => boolean { return (e) => { if (e.remoteKey === Keys.Enter) { handler(e); @@ -44,20 +48,37 @@ export const Pressable: ForwardRefExoticComponent = focusable< }, ref, ) { - const [state, setState] = useState({ pressed: false }); + const [state, setState] = useState({ focused: false, pressed: false }); - const handleFocus = useFocusHandler(onFocus); - const handleBlur = useBlurHandler(onBlur); + const forwardFocus = useFocusHandler(onFocus); + const forwardBlur = useBlurHandler(onBlur); const handleLayout = useLayoutHandler(onLayout); + // RN's Pressable exposes `focused` to its function children; mirror that by + // tracking it locally so focus-driven visuals (rings, scale) react. Wire the + // handlers unconditionally — consumer callbacks are optional and forwarded. + const handleFocus = ( + element: Parameters>[0], + ) => { + setState((s) => ({ ...s, focused: true })); + forwardFocus?.(element); + }; + + const handleBlur = ( + element: Parameters>[0], + ) => { + setState((s) => ({ ...s, focused: false })); + forwardBlur?.(element); + }; + const handleKeyDown = useEnterKeyHandler((e) => { onPressIn?.(createGestureResponderEvent(e, ref)); - setState({ pressed: true }); + setState((s) => ({ ...s, pressed: true })); }); const handleKeyUp = useEnterKeyHandler((e) => { onPressOut?.(createGestureResponderEvent(e, ref)); - setState({ pressed: false }); + setState((s) => ({ ...s, pressed: false })); }); const handleKeyPress = useEnterKeyHandler((e) => { @@ -75,13 +96,13 @@ export const Pressable: ForwardRefExoticComponent = focusable< ref={ref} style={finalStyle as ViewProps['style']} {...props} + onBlur={handleBlur} + onFocus={handleFocus} onKeyDown={handleKeyDown} - onKeyUp={handleKeyUp} onKeyPress={handleKeyPress} - onLongPress={handleLongPress} + onKeyUp={handleKeyUp} onLayout={handleLayout} - onFocus={handleFocus} - onBlur={handleBlur} + onLongPress={handleLongPress} > {typeof children === 'function' ? children(state) : children} diff --git a/packages/react-native-lightning/src/exports/findNodeHandle.ts b/packages/react-native-lightning/src/exports/findNodeHandle.ts new file mode 100644 index 00000000..81024772 --- /dev/null +++ b/packages/react-native-lightning/src/exports/findNodeHandle.ts @@ -0,0 +1,11 @@ +import type { LightningViewElement } from '@plextv/react-lightning'; + +// RN's findNodeHandle returns an opaque numeric node handle; react-native-web's +// throws. On Lightning the focus APIs (FocusGuide.setDestinations, focus hints) +// operate on element refs directly, so return the ref as-is. Keeps shared code +// that funnels refs through findNodeHandle working instead of crashing. +export function findNodeHandle( + componentOrHandle: unknown, +): LightningViewElement | null { + return (componentOrHandle as LightningViewElement | null) ?? null; +} diff --git a/packages/react-native-lightning/src/index.ts b/packages/react-native-lightning/src/index.ts index d454adf1..ff2c5234 100644 --- a/packages/react-native-lightning/src/index.ts +++ b/packages/react-native-lightning/src/index.ts @@ -25,6 +25,7 @@ export { type TouchableWithoutFeedbackProps, } from './exports/TouchableWithoutFeedback'; export { View, type ViewProps } from './exports/View'; +export { findNodeHandle } from './exports/findNodeHandle'; export { VirtualizedList } from './exports/VirtualizedList'; export { useBlurHandler, useFocusHandler } from './hooks/useFocusHandler'; diff --git a/patches/@lightningjs__renderer@3.1.1.patch b/patches/@lightningjs__renderer@3.1.1.patch new file mode 100644 index 00000000..7aaac64a --- /dev/null +++ b/patches/@lightningjs__renderer@3.1.1.patch @@ -0,0 +1,96 @@ +diff --git a/dist/src/core/CoreNode.js b/dist/src/core/CoreNode.js +index d4bb6f75a8c58b6b1409556d8ed2730c3a6fe551..61d3faddf3ff2969a9fdd25be9df2ad51b023eda 100644 +--- a/dist/src/core/CoreNode.js ++++ b/dist/src/core/CoreNode.js +@@ -979,12 +979,12 @@ export class CoreNode extends EventEmitter { + const ownRadius = clippingRect.clipRadius; + intersectRect(parentClippingRect, clippingRect, clippingRect); + clippingRect.clipRadius = ownRadius; +- // intersectRect writes {0,0,0,0} when the rects don't overlap but does +- // not touch the valid flag. An empty intersection means nothing is +- // visible — mark the rect invalid so children are not clipped to a +- // zero-area region and the stencil pass is skipped. ++ // An empty intersection means the subtree is fully clipped. Keep the ++ // rect valid with zero area so the scissor rejects everything; ++ // valid=false here would render children unclipped instead. + if (clippingRect.w <= 0 || clippingRect.h <= 0) { +- clippingRect.valid = false; ++ clippingRect.w = 0; ++ clippingRect.h = 0; + clippingRect.clipRadius = 0; + } + } +@@ -1139,7 +1139,9 @@ export class CoreNode extends EventEmitter { + autosizeTarget.attach(node); + } + if (inRttCluster === true) { +- node.markChildrenWithRTT(this); ++ // Walk the NEW child's subtree: nodes attach bottom-up, so its ++ // descendants were stamped false before this attach. ++ node.markChildrenWithRTT(); + } + children.push(node); + if (children.length === 1) { +diff --git a/dist/src/core/renderers/webgl/WebGlRenderer.js b/dist/src/core/renderers/webgl/WebGlRenderer.js +index 27f1d49d8096157c29b223dd0327f7b0f598d002..693f1e6eb8a6261716cff83d85c5d0459274a401 100644 +--- a/dist/src/core/renderers/webgl/WebGlRenderer.js ++++ b/dist/src/core/renderers/webgl/WebGlRenderer.js +@@ -688,7 +688,9 @@ export class WebGlRenderer extends CoreRenderer { + op.w = Math.round(cr.w * pixelRatio); + op.h = Math.round(cr.h * pixelRatio); + op.y = Math.round(canvas.height - op.h - cr.y * pixelRatio); +- op.clipRadius = cr.clipRadius * pixelRatio; ++ // Clamp to half the min side: a radius past that (e.g. borderRadius ++ // 9999 for a circle) breaks the rounded-rect SDF and clips everything. ++ op.clipRadius = Math.min(cr.clipRadius * pixelRatio, op.w / 2, op.h / 2); + op.pixelRatio = pixelRatio; + op.canvasHeight = canvas.height; + op.parentHasRenderTexture = node.parentHasRenderTexture; +diff --git a/src/core/CoreNode.ts b/src/core/CoreNode.ts +index fad32fb4acff160677c74eb91de43bf90e6f022b..3ee522354a33889e9a8bd2aa70184ff2164952f5 100644 +--- a/src/core/CoreNode.ts ++++ b/src/core/CoreNode.ts +@@ -1805,12 +1805,12 @@ export class CoreNode extends EventEmitter { + const ownRadius = clippingRect.clipRadius; + intersectRect(parentClippingRect, clippingRect, clippingRect); + clippingRect.clipRadius = ownRadius; +- // intersectRect writes {0,0,0,0} when the rects don't overlap but does +- // not touch the valid flag. An empty intersection means nothing is +- // visible — mark the rect invalid so children are not clipped to a +- // zero-area region and the stencil pass is skipped. ++ // An empty intersection means the subtree is fully clipped. Keep the ++ // rect valid with zero area so the scissor rejects everything; ++ // valid=false here would render children unclipped instead. + if (clippingRect.w <= 0 || clippingRect.h <= 0) { +- clippingRect.valid = false; ++ clippingRect.w = 0; ++ clippingRect.h = 0; + clippingRect.clipRadius = 0; + } + } else if (parentClippingRect.valid === true) { +@@ -1995,7 +1995,9 @@ export class CoreNode extends EventEmitter { + } + + if (inRttCluster === true) { +- node.markChildrenWithRTT(this); ++ // Walk the NEW child's subtree: nodes attach bottom-up, so its ++ // descendants were stamped false before this attach. ++ node.markChildrenWithRTT(); + } + + children.push(node); +diff --git a/src/core/renderers/webgl/WebGlRenderer.ts b/src/core/renderers/webgl/WebGlRenderer.ts +index 2780b17b4233d760f97f963133abbf5b6a0bd8c9..797518e33ab767fc777e9e65a6ee01ef3c8846da 100644 +--- a/src/core/renderers/webgl/WebGlRenderer.ts ++++ b/src/core/renderers/webgl/WebGlRenderer.ts +@@ -865,7 +865,9 @@ export class WebGlRenderer extends CoreRenderer { + op.w = Math.round(cr.w * pixelRatio); + op.h = Math.round(cr.h * pixelRatio); + op.y = Math.round(canvas.height - op.h - cr.y * pixelRatio); +- op.clipRadius = cr.clipRadius * pixelRatio; ++ // Clamp to half the min side: a radius past that (e.g. borderRadius ++ // 9999 for a circle) breaks the rounded-rect SDF and clips everything. ++ op.clipRadius = Math.min(cr.clipRadius * pixelRatio, op.w / 2, op.h / 2); + op.pixelRatio = pixelRatio; + op.canvasHeight = canvas.height; + op.parentHasRenderTexture = node.parentHasRenderTexture; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0a969580..b130cef8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,8 +7,8 @@ settings: catalogs: apps: '@lightningjs/renderer': - specifier: 3.0.1 - version: 3.0.1 + specifier: 3.1.1 + version: 3.1.1 react: specifier: 19.2.5 version: 19.2.5 @@ -23,8 +23,8 @@ catalogs: version: 4.3.0 default: '@lightningjs/renderer': - specifier: 3.0.1 - version: 3.0.1 + specifier: 3.1.1 + version: 3.1.1 '@rolldown/plugin-babel': specifier: ^0.2.3 version: 0.2.3 @@ -65,6 +65,11 @@ catalogs: specifier: ^8.0.0 version: 8.0.8 +patchedDependencies: + '@lightningjs/renderer@3.1.1': + hash: 8a9b52055d19d3441ef640e67ba0f603d80233d87dcea76490db2810bb262623 + path: patches/@lightningjs__renderer@3.1.1.patch + importers: .: @@ -134,7 +139,7 @@ importers: dependencies: '@lightningjs/renderer': specifier: catalog:apps - version: 3.0.1 + version: 3.1.1(patch_hash=8a9b52055d19d3441ef640e67ba0f603d80233d87dcea76490db2810bb262623) '@plextv/react-lightning': specifier: workspace:* version: link:../../packages/react-lightning @@ -189,7 +194,7 @@ importers: dependencies: '@lightningjs/renderer': specifier: catalog:apps - version: 3.0.1 + version: 3.1.1(patch_hash=8a9b52055d19d3441ef640e67ba0f603d80233d87dcea76490db2810bb262623) '@plextv/react-lightning': specifier: workspace:* version: link:../../packages/react-lightning @@ -268,7 +273,7 @@ importers: dependencies: '@lightningjs/renderer': specifier: catalog:apps - version: 3.0.1 + version: 3.1.1(patch_hash=8a9b52055d19d3441ef640e67ba0f603d80233d87dcea76490db2810bb262623) '@plextv/react-lightning': specifier: workspace:* version: link:../../packages/react-lightning @@ -359,7 +364,7 @@ importers: dependencies: '@lightningjs/renderer': specifier: 'catalog:' - version: 3.0.1 + version: 3.1.1(patch_hash=8a9b52055d19d3441ef640e67ba0f603d80233d87dcea76490db2810bb262623) '@plextv/react-lightning': specifier: workspace:^ version: link:../react-lightning @@ -422,7 +427,7 @@ importers: dependencies: '@lightningjs/renderer': specifier: 'catalog:' - version: 3.0.1 + version: 3.1.1(patch_hash=8a9b52055d19d3441ef640e67ba0f603d80233d87dcea76490db2810bb262623) '@plextv/react-lightning': specifier: workspace:^ version: link:../react-lightning @@ -459,7 +464,7 @@ importers: dependencies: '@lightningjs/renderer': specifier: 'catalog:' - version: 3.0.1 + version: 3.1.1(patch_hash=8a9b52055d19d3441ef640e67ba0f603d80233d87dcea76490db2810bb262623) react: specifier: 'catalog:' version: 19.2.5 @@ -506,7 +511,7 @@ importers: dependencies: '@lightningjs/renderer': specifier: 'catalog:' - version: 3.0.1 + version: 3.1.1(patch_hash=8a9b52055d19d3441ef640e67ba0f603d80233d87dcea76490db2810bb262623) '@plextv/react-lightning': specifier: workspace:^ version: link:../react-lightning @@ -1655,8 +1660,8 @@ packages: resolution: {integrity: sha512-FHIgj5rkOQPd9/wDXaiR0GOoWDEj7BytIzvYq5K8/wAh3z2bbW8gTNN+0J5kc1KXtqPrbyg1i87ksUVLrLEr1g==} engines: {node: '>=18.0.0'} - '@lightningjs/renderer@3.0.1': - resolution: {integrity: sha512-xAn5eVtYdAmpqA8rN5/f4LnF/48cqrC204s1Mv51KL6GylYHCdhzdGqX480Apgiq3ub+DzNDgNs/xhTASzi7hQ==} + '@lightningjs/renderer@3.1.1': + resolution: {integrity: sha512-L1+9ZN13+mH5vz7wQOF9+edghWA8N7ETi1d8cFC2SCp+7evmB7utz/NROfUCZyDhOLXlXREN2GPcELTBXeU0wg==} engines: {node: '>= 18.0.0', npm: '>= 10.0.0', pnpm: '>= 10.17.0'} '@manypkg/find-root@1.1.0': @@ -6788,7 +6793,7 @@ snapshots: msdf-bmfont-xml: 2.8.0 opentype.js: 1.3.4 - '@lightningjs/renderer@3.0.1': {} + '@lightningjs/renderer@3.1.1(patch_hash=8a9b52055d19d3441ef640e67ba0f603d80233d87dcea76490db2810bb262623)': {} '@manypkg/find-root@1.1.0': dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5ecf2aac..189db423 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,7 +3,7 @@ packages: - packages/* catalog: - '@lightningjs/renderer': 3.0.1 + '@lightningjs/renderer': 3.1.1 '@rolldown/plugin-babel': ^0.2.3 '@types/react': 19.2.14 '@types/react-dom': 19.2.3 @@ -21,13 +21,16 @@ catalog: catalogs: apps: - '@lightningjs/renderer': 3.0.1 + '@lightningjs/renderer': 3.1.1 '@vitejs/plugin-react': 6.0.1 react: 19.2.5 react-dom: 19.2.5 react-native: 0.85.1 react-native-reanimated: 4.3.0 +patchedDependencies: + '@lightningjs/renderer@3.1.1': patches/@lightningjs__renderer@3.1.1.patch + onlyBuiltDependencies: - core-js - esbuild