diff --git a/.changeset/measure-children-layout.md b/.changeset/measure-children-layout.md new file mode 100644 index 0000000..b0290a2 --- /dev/null +++ b/.changeset/measure-children-layout.md @@ -0,0 +1,7 @@ +--- +'@noriginmedia/norigin-spatial-navigation-core': minor +'@noriginmedia/norigin-spatial-navigation-react': minor +'@noriginmedia/norigin-spatial-navigation': minor +--- + +Add `measureChildrenLayout` option to `useFocusable` (default `true`) to control whether a container's direct children are measured during navigation and `updateAllLayouts`. Set it to `false` on containers driven by their own `nextFocusResolver` to skip layout measurement that the resolver doesn't need. diff --git a/docs/api-reference/useFocusable.md b/docs/api-reference/useFocusable.md index 8a850c1..8f05bc6 100644 --- a/docs/api-reference/useFocusable.md +++ b/docs/api-reference/useFocusable.md @@ -22,25 +22,27 @@ The generic parameter `P` is the type of `extraProps`. The parameter `E` is the All options are optional. -| Option | Type | Default | Description | -| ------------------------- | ------------------------ | -------------- | -------------------------------------------------------------------------------------------------------------------------------------- | -| `focusable` | `boolean` | `true` | Whether this component can receive focus. Set to `false` to temporarily disable a component without unmounting it. | -| `saveLastFocusedChild` | `boolean` | `true` | When focus returns to this container, restore focus to the last focused child instead of the first. | -| `trackChildren` | `boolean` | `false` | Update `hasFocusedChild` when any descendant gains or loses focus. Must be `true` to use `hasFocusedChild` for styling. | -| `autoRestoreFocus` | `boolean` | `true` | If this component is focused when it unmounts, automatically restore focus to the nearest other component. | -| `forceFocus` | `boolean` | `false` | Mark this component as the preferred fallback target when focus is lost and no other candidate exists. | -| `isFocusBoundary` | `boolean` | `false` | Prevent focus from leaving this container in any direction. See [Focus Boundaries](../guides/focus-boundaries.md). | -| `focusBoundaryDirections` | `Direction[]` | `undefined` | Limit boundary behavior to specific directions only (e.g., `['up', 'left']`). Only used when `isFocusBoundary` is `true`. | -| `focusKey` | `string` | auto-generated | A stable, unique identifier for this component. Required for programmatic focus via `setFocus`. | -| `preferredChildFocusKey` | `string` | `undefined` | Focus key of the child that should receive focus when this container is first entered. | -| `onEnterPress` | `EnterPressHandler
` | no-op | Called when the Enter key is pressed while this component is focused. | -| `onEnterRelease` | `EnterReleaseHandler
` | no-op | Called when the Enter key is released. | -| `onArrowPress` | `ArrowPressHandler
` | `() => true` | Called when an arrow key is pressed. Return `true` to allow default navigation, `false` to prevent it. | -| `onArrowRelease` | `ArrowReleaseHandler
` | no-op | Called when an arrow key is released. | -| `onFocus` | `FocusHandler
` | no-op | Called when this component gains focus. | -| `onBlur` | `BlurHandler
` | no-op | Called when this component loses focus. | -| `extraProps` | `P` | `undefined` | Arbitrary data passed as the first argument to all event callbacks. Use this to avoid closure stale-state issues. | -| `accessibilityLabel` | `string` | `undefined` | Text uttered by the global `onUtterText` callback when this component is focused. See [accessibilityLabel](#accessibilitylabel) below. | +| Option | Type | Default | Description | +| ------------------------- | ------------------------ | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `focusable` | `boolean` | `true` | Whether this component can receive focus. Set to `false` to temporarily disable a component without unmounting it. | +| `saveLastFocusedChild` | `boolean` | `true` | When focus returns to this container, restore focus to the last focused child instead of the first. | +| `trackChildren` | `boolean` | `false` | Update `hasFocusedChild` when any descendant gains or loses focus. Must be `true` to use `hasFocusedChild` for styling. | +| `autoRestoreFocus` | `boolean` | `true` | If this component is focused when it unmounts, automatically restore focus to the nearest other component. | +| `forceFocus` | `boolean` | `false` | Mark this component as the preferred fallback target when focus is lost and no other candidate exists. | +| `isFocusBoundary` | `boolean` | `false` | Prevent focus from leaving this container in any direction. See [Focus Boundaries](../guides/focus-boundaries.md). | +| `focusBoundaryDirections` | `Direction[]` | `undefined` | Limit boundary behavior to specific directions only (e.g., `['up', 'left']`). Only used when `isFocusBoundary` is `true`. | +| `focusKey` | `string` | auto-generated | A stable, unique identifier for this component. Required for programmatic focus via `setFocus`. | +| `preferredChildFocusKey` | `string` | `undefined` | Focus key of the child that should receive focus when this container is first entered. | +| `nextFocusResolver` | `NextFocusResolver` | `undefined` | Override default coordinate-based navigation for this container's direct children. See [Next Focus Resolver](../guides/next-focus-resolver.md). | +| `measureChildrenLayout` | `boolean` | `true` | When `false`, this container's direct children are not measured during navigation or `updateAllLayouts`. See [Next Focus Resolver](../guides/next-focus-resolver.md#measurechildrenlayout). | +| `onEnterPress` | `EnterPressHandler
` | no-op | Called when the Enter key is pressed while this component is focused. | +| `onEnterRelease` | `EnterReleaseHandler
` | no-op | Called when the Enter key is released. | +| `onArrowPress` | `ArrowPressHandler
` | `() => true` | Called when an arrow key is pressed. Return `true` to allow default navigation, `false` to prevent it. | +| `onArrowRelease` | `ArrowReleaseHandler
` | no-op | Called when an arrow key is released. | +| `onFocus` | `FocusHandler
` | no-op | Called when this component gains focus. | +| `onBlur` | `BlurHandler
` | no-op | Called when this component loses focus. |
+| `extraProps` | `P` | `undefined` | Arbitrary data passed as the first argument to all event callbacks. Use this to avoid closure stale-state issues. |
+| `accessibilityLabel` | `string` | `undefined` | Text uttered by the global `onUtterText` callback when this component is focused. See [accessibilityLabel](#accessibilitylabel) below. |
---
@@ -292,5 +294,5 @@ With the tree above, landing focus on `Inception` utters `"Movies, Inception"`.
## Notes
- The `ref` must be attached to a DOM element that has non-zero width and height when the component mounts. If the element is zero-sized, the library cannot measure its position and navigation to/from it will not work correctly.
-- Config options other than `focusKey`, `focusable`, `isFocusBoundary`, `focusBoundaryDirections`, `preferredChildFocusKey`, and `accessibilityLabel` are **not** reactive after mount. Callbacks are updated via a separate effect, but structural options like `saveLastFocusedChild`, `trackChildren`, `autoRestoreFocus`, and `forceFocus` are only read at registration time.
+- Config options other than `focusKey`, `focusable`, `isFocusBoundary`, `focusBoundaryDirections`, `preferredChildFocusKey`, `nextFocusResolver`, `measureChildrenLayout`, and `accessibilityLabel` are **not** reactive after mount. Callbacks are updated via a separate effect, but structural options like `saveLastFocusedChild`, `trackChildren`, `autoRestoreFocus`, and `forceFocus` are only read at registration time.
- Use `extraProps` to pass data to callbacks instead of relying on closure variables. This avoids stale closure issues with callbacks that reference component props.
diff --git a/docs/guides/accessibility-labels.md b/docs/guides/accessibility-labels.md
index a43971e..3d342fe 100644
--- a/docs/guides/accessibility-labels.md
+++ b/docs/guides/accessibility-labels.md
@@ -1,5 +1,5 @@
---
-sidebar_position: 14
+sidebar_position: 16
---
# Accessibility Labels
diff --git a/docs/guides/debugging.md b/docs/guides/debugging.md
index 6d0182a..cd02585 100644
--- a/docs/guides/debugging.md
+++ b/docs/guides/debugging.md
@@ -1,5 +1,5 @@
---
-sidebar_position: 12
+sidebar_position: 13
---
# Debugging
diff --git a/docs/guides/distance-calculation.md b/docs/guides/distance-calculation.md
index e131d34..d22706e 100644
--- a/docs/guides/distance-calculation.md
+++ b/docs/guides/distance-calculation.md
@@ -156,3 +156,12 @@ init({
```
This makes it easy to understand why a particular element wins or loses the distance calculation. See [Debugging](./debugging.md) for more.
+
+---
+
+## Overriding the Algorithm
+
+When distance-based scoring isn't the right fit for a container — for example, a carousel that
+should always move by exactly one item — a `nextFocusResolver` lets that container supply its own
+navigation logic entirely, bypassing this algorithm. See
+[Next Focus Resolver](./next-focus-resolver.md).
diff --git a/docs/guides/key-mapping.md b/docs/guides/key-mapping.md
index bdf776d..7b86a15 100644
--- a/docs/guides/key-mapping.md
+++ b/docs/guides/key-mapping.md
@@ -1,5 +1,5 @@
---
-sidebar_position: 9
+sidebar_position: 10
---
# Key Mapping
diff --git a/docs/guides/next-focus-resolver.md b/docs/guides/next-focus-resolver.md
new file mode 100644
index 0000000..1e03a30
--- /dev/null
+++ b/docs/guides/next-focus-resolver.md
@@ -0,0 +1,108 @@
+---
+sidebar_position: 9
+---
+
+# Next Focus Resolver
+
+[Distance Calculation](./distance-calculation.md) explains how the library picks the next
+sibling by default: it scores every candidate and focuses the lowest-scoring one. `nextFocusResolver`
+lets a container replace that entirely with your own logic.
+
+## `nextFocusResolver`
+
+Set on the **container**, `nextFocusResolver` is called whenever an arrow key would move focus
+between its direct children. Its return value decides which child (if any) gets focused —
+default coordinate-based navigation for that container is bypassed completely.
+
+```typescript
+import { useFocusable } from '@noriginmedia/norigin-spatial-navigation-react';
+
+const resolver: NextFocusResolver = (direction, currentFocusKey, siblings) => {
+ const currentIndex = siblings.findIndex(
+ (sibling) => sibling.focusKey === currentFocusKey
+ );
+
+ if (direction === 'right') {
+ return siblings[currentIndex + 1] ?? null;
+ }
+
+ if (direction === 'left') {
+ return siblings[currentIndex - 1] ?? null;
+ }
+
+ return null; // fall through to the parent container for up/down
+};
+
+function Carousel() {
+ const { ref, focusKey } = useFocusable({ nextFocusResolver: resolver });
+
+ // ...
+}
+```
+
+### Signature
+
+```typescript
+type NextFocusResolver = (
+ direction: Direction,
+ currentFocusKey: string,
+ siblings: FocusableComponent[]
+) => FocusableComponent | null;
+```
+
+- `direction` — the arrow key that was pressed.
+- `currentFocusKey` — the focus key of the currently focused child.
+- `siblings` — every **focusable** direct child of the container the resolver is set on (not
+ grandchildren, and not children filtered by direction or position — that filtering is now your
+ responsibility).
+
+### Return value
+
+- Return one of the objects from `siblings` to focus it.
+- Return `null` to decline the navigation for this direction. The library then falls through to
+ the container's own parent, exactly as it would if no sibling qualified under default
+ navigation.
+
+Returning an object that isn't a member of the `siblings` array you were given results in lost
+focus. In development (`debug: true`), the library warns to the console when this happens,
+naming the container's `focusKey`.
+
+---
+
+## `measureChildrenLayout`
+
+By default, every registered component's on-screen position is re-measured as needed to support
+coordinate-based navigation and the [distance calculation](./distance-calculation.md) algorithm.
+Once a container supplies its own `nextFocusResolver`, that measurement is often unnecessary —
+your resolver already knows what the next component should be without consulting layout.
+
+Set `measureChildrenLayout: false` on the same container to skip measuring its **direct
+children** during navigation and `updateAllLayouts()`. See
+[Performance Tuning](./performance.md#skipping-layout-measurement) for the perf motivation and
+exactly which measurements are skipped.
+
+```typescript
+const { ref, focusKey } = useFocusable({
+ nextFocusResolver: resolver,
+ measureChildrenLayout: false
+});
+```
+
+### The staleness caveat
+
+`measureChildrenLayout: false` does not clear the `layout` already stored on each child — it
+leaves it as whatever was last measured. If your `nextFocusResolver` reads `sibling.layout` (for
+example, to pick the visually nearest child), those coordinates will silently go stale as the app
+scrolls or re-renders, since nothing is refreshing them anymore.
+
+Only opt out of measurement when your resolver decides purely from `direction`, `currentFocusKey`,
+and the shape of `siblings` (order, count, custom data you attach elsewhere) — not from
+`sibling.layout`.
+
+Because default navigation is coordinate-based, `measureChildrenLayout: false` is only meaningful
+paired with a `nextFocusResolver` on the same container. In development (`debug: true`), the
+library warns to the console if a container has the flag set, no resolver, and more than one
+focusable child — that combination reliably breaks arrow-key navigation between its children.
+
+`measureChildrenLayout` only affects **direct** children. A nested container underneath one that
+sets it to `false` still measures its own children normally unless it also opts out.
diff --git a/docs/guides/performance.md b/docs/guides/performance.md
index 00a4143..5893216 100644
--- a/docs/guides/performance.md
+++ b/docs/guides/performance.md
@@ -1,5 +1,5 @@
---
-sidebar_position: 11
+sidebar_position: 12
---
# Performance Tuning
@@ -82,6 +82,33 @@ Use viewport-relative layout when:
`getBoundingClientRect` is slightly slower because it triggers a layout reflow, but it accounts for CSS transforms that `offsetLeft/Top` ignores.
+### Skipping Layout Measurement
+
+Coordinate-based navigation needs a fresh position for every candidate on each keypress, which is
+the most expensive part of layout measurement — on React Native TV in particular, each measurement
+is a bridge round-trip. A container driven by its own [`nextFocusResolver`](./next-focus-resolver.md)
+already knows the next component without consulting coordinates, so that measurement is pure
+overhead.
+
+Set `measureChildrenLayout: false` on such a container to skip measuring its **direct children**
+in two places:
+
+- The per-keypress sibling measurement inside navigation.
+- The bulk `updateAllLayouts()` call.
+
+Children are still measured once on mount, and still measured on focus/blur (so `onFocus`/`onBlur`
+continue to receive accurate layout) — only the navigation-time re-measurement is skipped.
+
+```typescript
+const { ref, focusKey } = useFocusable({
+ nextFocusResolver: resolver,
+ measureChildrenLayout: false
+});
+```
+
+See [Next Focus Resolver](./next-focus-resolver.md#measurechildrenlayout) for the full contract,
+including the staleness caveat for resolvers that read `sibling.layout`.
+
### Async work ordering
The core engine runs navigation- and layout-related async work through an internal **scheduler**, so operations are serialized instead of interleaving arbitrarily. When you need logic to run after focus or navigation completes, use **`await setFocus`** / **`await navigateByDirection`** (see [Programmatic focus](./programmatic-focus.md)).
diff --git a/docs/guides/react-native-tv.md b/docs/guides/react-native-tv.md
index 75f42dc..1562ae8 100644
--- a/docs/guides/react-native-tv.md
+++ b/docs/guides/react-native-tv.md
@@ -1,5 +1,5 @@
---
-sidebar_position: 13
+sidebar_position: 14
---
# React Native TV
diff --git a/docs/guides/recipes.md b/docs/guides/recipes.md
index b4f040e..3cbb827 100644
--- a/docs/guides/recipes.md
+++ b/docs/guides/recipes.md
@@ -1,5 +1,5 @@
---
-sidebar_position: 14
+sidebar_position: 15
---
# Recipes & Patterns
diff --git a/docs/guides/rtl-support.md b/docs/guides/rtl-support.md
index cacec48..a2ba6a8 100644
--- a/docs/guides/rtl-support.md
+++ b/docs/guides/rtl-support.md
@@ -1,5 +1,5 @@
---
-sidebar_position: 10
+sidebar_position: 11
---
# RTL Support
diff --git a/packages/core/src/SpatialNavigation.ts b/packages/core/src/SpatialNavigation.ts
index dded3c1..112a2be 100644
--- a/packages/core/src/SpatialNavigation.ts
+++ b/packages/core/src/SpatialNavigation.ts
@@ -119,6 +119,13 @@ export interface FocusableComponent {
autoRestoreFocus: boolean;
forceFocus: boolean;
nextFocusResolver?: NextFocusResolver;
+ /**
+ * When false, direct children of this component are not measured during
+ * navigation (`smartNavigate`) or `updateAllLayouts`. Only meaningful
+ * alongside `nextFocusResolver`, since default coordinate-based navigation
+ * requires fresh child layouts. Defaults to true when undefined.
+ */
+ measureChildrenLayout?: boolean;
lastFocusedChildKey?: string;
layout?: FocusableComponentLayout;
layoutUpdatedAt?: number;
@@ -139,6 +146,7 @@ interface FocusableComponentUpdatePayload {
onBlur: (layout: FocusableComponentLayout, details: FocusDetails) => void;
accessibilityLabel?: string;
nextFocusResolver?: NextFocusResolver;
+ measureChildrenLayout?: boolean;
}
interface FocusableComponentRemovePayload {
@@ -1126,8 +1134,17 @@ export class SpatialNavigationService {
);
if (currentComponent) {
- await this.updateLayout(currentComponent.focusKey);
- const { parentFocusKey, focusKey, layout } = currentComponent;
+ const { parentFocusKey, focusKey } = currentComponent;
+
+ const currentParentComponent = this.focusableComponents[parentFocusKey];
+ const { nextFocusResolver, measureChildrenLayout = true } =
+ currentParentComponent ?? {};
+
+ if (measureChildrenLayout) {
+ await this.updateLayout(currentComponent.focusKey);
+ }
+
+ const { layout } = currentComponent;
const currentCutoffCoordinate =
SpatialNavigationService.getCutoffCoordinate(
@@ -1138,26 +1155,25 @@ export class SpatialNavigationService {
this.writingDirection
);
- /**
- * Get only the siblings with the coords on the way of our moving direction
- */
- const threshold = Date.now() - LAYOUT_STALE_TIME;
- await Promise.all(
- Object.values(this.focusableComponents)
- .filter(
- (component) =>
- component.parentFocusKey === parentFocusKey &&
- component.focusable &&
- component.layoutUpdatedAt <= threshold
- )
- .map((component) => this.updateLayout(component.focusKey))
- );
+ if (measureChildrenLayout) {
+ /**
+ * Get only the siblings with the coords on the way of our moving direction
+ */
+ const threshold = Date.now() - LAYOUT_STALE_TIME;
+ await Promise.all(
+ Object.values(this.focusableComponents)
+ .filter(
+ (component) =>
+ component.parentFocusKey === parentFocusKey &&
+ component.focusable &&
+ component.layoutUpdatedAt <= threshold
+ )
+ .map((component) => this.updateLayout(component.focusKey))
+ );
+ }
let nextComponent: FocusableComponent | null = null;
- const { nextFocusResolver } =
- this.focusableComponents[parentFocusKey] ?? {};
-
if (nextFocusResolver) {
const siblings = filter(
this.focusableComponents,
@@ -1184,6 +1200,20 @@ export class SpatialNavigationService {
);
}
} else {
+ if (this.debug && !measureChildrenLayout) {
+ const focusableSiblingCount = filter(
+ this.focusableComponents,
+ (component) =>
+ component.parentFocusKey === parentFocusKey && component.focusable
+ ).length;
+
+ if (focusableSiblingCount >= 2) {
+ console.warn(
+ `measureChildrenLayout is false but no nextFocusResolver is set for component with focusKey: ${parentFocusKey}. Default coordinate-based navigation requires fresh layout data and may behave incorrectly.`
+ );
+ }
+ }
+
const siblings = filter(this.focusableComponents, (component) => {
if (
component.parentFocusKey === parentFocusKey &&
@@ -1458,7 +1488,8 @@ export class SpatialNavigationService {
isFocusBoundary,
focusBoundaryDirections,
accessibilityLabel,
- nextFocusResolver
+ nextFocusResolver,
+ measureChildrenLayout
}: FocusableComponent) {
this.focusableComponents[focusKey] = {
focusKey,
@@ -1473,6 +1504,7 @@ export class SpatialNavigationService {
onUpdateFocus,
onUpdateHasFocusedChild,
nextFocusResolver,
+ measureChildrenLayout,
saveLastFocusedChild,
trackChildren,
preferredChildFocusKey,
@@ -1889,9 +1921,13 @@ export class SpatialNavigationService {
}
await Promise.all(
- Object.keys(this.focusableComponents).map((focusKey) =>
- this.updateLayout(focusKey)
- )
+ Object.values(this.focusableComponents)
+ .filter(
+ (component) =>
+ this.focusableComponents[component.parentFocusKey]
+ ?.measureChildrenLayout !== false
+ )
+ .map((component) => this.updateLayout(component.focusKey))
);
}
@@ -1920,7 +1956,8 @@ export class SpatialNavigationService {
onFocus,
onBlur,
accessibilityLabel,
- nextFocusResolver
+ nextFocusResolver,
+ measureChildrenLayout
}: FocusableComponentUpdatePayload
) {
const component = this.focusableComponents[focusKey];
@@ -1937,6 +1974,7 @@ export class SpatialNavigationService {
component.onBlur = onBlur;
component.accessibilityLabel = accessibilityLabel;
component.nextFocusResolver = nextFocusResolver;
+ component.measureChildrenLayout = measureChildrenLayout;
// Reset layout updated at to force a layout update
component.layoutUpdatedAt = 0;
diff --git a/packages/core/src/__tests__/SpatialNavigation.test.ts b/packages/core/src/__tests__/SpatialNavigation.test.ts
index 9c252db..9a8c26b 100644
--- a/packages/core/src/__tests__/SpatialNavigation.test.ts
+++ b/packages/core/src/__tests__/SpatialNavigation.test.ts
@@ -2,10 +2,13 @@ import {
ROOT_FOCUS_KEY,
SpatialNavigation,
destroy,
- init
+ init,
+ type NextFocusResolver
} from '../SpatialNavigation';
+import { measureLayout } from '../measureLayout';
import {
createHorizontalLayout,
+ createHorizontalLayoutWithResolver,
createRootNode,
createVerticalLayout
} from './domNodes';
@@ -15,6 +18,28 @@ const settle = () =>
setTimeout(resolve, 0);
});
+const wait = (ms: number) =>
+ new Promise {
focusKey?: string;
preferredChildFocusKey?: string;
nextFocusResolver?: NextFocusResolver;
+ /**
+ * When false, direct children of this component are not measured during
+ * navigation or `updateAllLayouts`. Only meaningful together with
+ * `nextFocusResolver`, since default coordinate-based navigation requires
+ * fresh child layouts.
+ */
+ measureChildrenLayout?: boolean;
onEnterPress?: EnterPressHandler ;
onEnterRelease?: EnterReleaseHandler ;
onArrowPress?: ArrowPressHandler ;
@@ -93,6 +100,7 @@ const useFocusableHook = ({
focusKey: propFocusKey,
preferredChildFocusKey,
nextFocusResolver,
+ measureChildrenLayout = true,
onEnterPress = noop,
onEnterRelease = noop,
onArrowPress = () => true,
@@ -171,6 +179,7 @@ const useFocusableHook = ({
parentFocusKey,
preferredChildFocusKey,
nextFocusResolver,
+ measureChildrenLayout,
onEnterPress: onEnterPressHandler,
onEnterRelease: onEnterReleaseHandler,
onArrowPress: onArrowPressHandler,
@@ -207,6 +216,7 @@ const useFocusableHook = ({
isFocusBoundary,
focusBoundaryDirections,
nextFocusResolver,
+ measureChildrenLayout,
onEnterPress: onEnterPressHandler,
onEnterRelease: onEnterReleaseHandler,
onArrowPress: onArrowPressHandler,
@@ -222,6 +232,7 @@ const useFocusableHook = ({
isFocusBoundary,
focusBoundaryDirections,
nextFocusResolver,
+ measureChildrenLayout,
onEnterPressHandler,
onEnterReleaseHandler,
onArrowPressHandler,