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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/bright-refs-flow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@fluentui-react-native/components": minor
---

Expose React 19 ref props for components and primitives with stable native roots.
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ Set `displayName` for diagnostics and Storybook metadata. Keep unstable stage fu
future composition work can identify them. Do not wrap the component in memoization or another boundary without measured
need and repository precedent.

Under this package's React 19.1.4 baseline, `ref` is an ordinary component prop. Include it in the public root props and
let the state hook forward it to the resolved root slot; do not wrap assembly components in `forwardRef`. If rendering a
second internal ref on the same slot, pass it in JSX so the slot runtime composes it with the captured consumer ref.

After assembly, export `use<Component>_unstable`, `use<Component>Styles_unstable`, and
`render<Component>_unstable` from the package root together with `<Component>State`, according to the
[types and slots export rules](types-and-slots.md#exports). These stages are intentionally unstable but public so other
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,19 +34,27 @@ enables behavior; Button uses `selected !== undefined` to distinguish an ordinar

Do not blindly inherit every root prop when the component owns part of the native contract.

1. Start with the root native props.
1. Start with `PropsWithRefOf<typeof Root>` so the native root's ref is part of the public contract.
2. Omit props the component controls, such as `children` or token-derived `style`.
3. Reintroduce a narrowed form only when consumers need it.

Button uses `Omit<PressableProps, 'children' | 'style'>` and adds `StyleProp<ViewStyle>` back explicitly. This prevents
native children from bypassing slot order while preserving a user style that can be applied after component styles.
Button uses `OwnedRootProps<PropsWithRefOf<typeof Pressable>>`. This prevents native children from bypassing slot order,
preserves the native style type, and retains `ref`.

Compose the final public props with:

```ts
export type MyComponentProps = MyComponentStateProps & ComponentProps<MyComponentSlots, ExposedRootProps>;
```

This package targets React 19.1.4 or newer, where function components receive `ref` as a prop. Let the state hook carry
that prop to the declared root slot; do not wrap the component in `forwardRef`. A top-level component ref always targets
the declared root. Consumers that need an inner public slot use that slot's own `ref`.

When a component needs an internal root ref as well, keep both refs and pass one through the resolved slot and the other
when rendering the slot so Framework Base composes them. Never overwrite the consumer ref. If a primitive can render
unrelated native instance types, omit a top-level ref unless it can define one sound, stable imperative contract.

## Make resolved state complete

Build state from:
Expand Down Expand Up @@ -89,5 +97,6 @@ internal style definitions.
- Variant unions match the spec exactly.
- Omitted and false values retain distinct semantics where required.
- Root native props cannot bypass owned children or style ordering.
- Stable native roots expose a correctly typed `ref`, and the state/render pipeline does not replace it.
- Resolved state contains every defaulted and derived value needed downstream.
- Public component, state, style-application, render, and resolved-state exports are explicit and tree-shakeable.
2 changes: 2 additions & 0 deletions packages/agentic/components/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ invariants; detailed authoring recipes live in the
- Use `src/components/button` as the canonical higher-order implementation and `src/primitives/icon` as the canonical
primitive.
- Keep public props and slots small, typed, and spec-driven.
- This package targets React 19.1.4 or newer. Expose a stable native root ref through the `ref` prop, receive it as part
of the component props, and forward it to the declared root slot. Do not use `forwardRef`.
- Export higher-order components and public types explicitly from `src/index.ts`; export primitives and their public types
explicitly from `src/primitives/index.ts`. Never use wildcard exports.
- Colocate runtime tests, type tests, and Storybook stories with the implementation.
Expand Down
23 changes: 16 additions & 7 deletions packages/agentic/components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"@babel/core": "catalog:",
"@fluentui-react-native/desktop-driver": "workspace:*",
"@fluentui-react-native/scripts": "workspace:*",
"@office-iss/react-native-win32": "^0.81.0",
"@react-native-community/cli": "^20.0.0",
"@react-native-community/cli-platform-android": "^20.0.0",
"@react-native-community/cli-platform-ios": "^20.0.0",
Expand All @@ -58,16 +59,17 @@
"react-native": "^0.81.6",
"react-native-macos": "^0.81.0",
"react-native-svg": "^15.12.1",
"react-native-windows": "^0.81.0",
"test-renderer": "catalog:"
},
"peerDependencies": {
"@office-iss/react-native-win32": "^0.74.0 || ^0.81.0",
"@types/react": "~18.2.0 || ~19.0.0 || ~19.1.4",
"react": "18.2.0 || 19.0.0 || 19.1.4",
"react-native": "^0.73.0 || ^0.74.0 || ^0.78.0 || ^0.81.6",
"react-native-macos": "^0.73.0 || ^0.74.0 || ^0.78.0 || ^0.81.0",
"react-native-svg": ">=15.0.0 <15.13.0 || >=15.4.0 <15.13.0 || ^15.11.2 || ^15.12.1",
"react-native-windows": "^0.73.0 || ^0.74.0 || ^0.78.0 || ^0.81.0"
"@office-iss/react-native-win32": "^0.81.0",
"@types/react": "~19.1.4",
"react": "19.1.4",
"react-native": "^0.81.6",
"react-native-macos": "^0.81.0",
"react-native-svg": "^15.12.1",
"react-native-windows": "^0.81.0"
},
"peerDependenciesMeta": {
"@office-iss/react-native-win32": {
Expand All @@ -94,9 +96,16 @@
"rnx-kit": {
"kitType": "library",
"alignDeps": {
"requirements": {
"production": [
"react-native@0.81"
]
},
"capabilities": [
"core",
"core-macos",
"core-win32",
"core-windows",
"svg"
]
},
Expand Down
3 changes: 3 additions & 0 deletions packages/agentic/components/src/components/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ audit.
- Create style factories only at module scope and cache theme-only styles.
- Declare state precedence explicitly; disabled wins over pressed, which wins over hovered.
- Apply user styles after component styles.
- Include the declared native root's `ref` in public props with `PropsWithRefOf<typeof Root>`. React 19.1.4 supplies
`ref` as a prop, so pass it through the state hook to the root slot without `forwardRef`. If the component also needs
an internal root ref, compose the refs through the slot render path rather than replacing the consumer ref.
- Render focus feedback through `FocusVisual`; do not add `outline*` props or enable RNW native
focus visuals because RNW 0.81 can fail-fast when either path creates border visuals after mount.
- Keep render functions free of hooks, token reads, style creation, and slot mutation.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import type { AccessibilityState, Pressable, StyleProp, Text, View, ViewProps, ViewStyle } from 'react-native';
import type { AccessibilityState, Pressable, StyleProp, Text, View, ViewStyle } from 'react-native';
import type {
ComponentProps,
ComponentState,
OptionalSlot,
OwnedRootProps,
PressableState,
PropsWithRefOf,
Slot,
} from '@fluentui-react-native/framework-base';
import type { ThemeState } from '@fluentui-react-native/design';
Expand Down Expand Up @@ -85,7 +86,7 @@ export type AccordionStateProps = {
};

export type AccordionRootProps = OwnedRootProps<
ViewProps,
PropsWithRefOf<typeof View>,
'accessible' | 'accessibilityHint' | 'accessibilityLabel' | 'accessibilityRole' | 'accessibilityState' | 'focusable'
>;

Expand Down
13 changes: 10 additions & 3 deletions packages/agentic/components/src/components/avatar/avatar.types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import type { Image, StyleProp, Text, View, ViewProps, ViewStyle } from 'react-native';
import type { ComponentProps, ComponentState, OptionalSlot, OwnedRootProps, Slot } from '@fluentui-react-native/framework-base';
import type { Image, StyleProp, Text, View, ViewStyle } from 'react-native';
import type {
ComponentProps,
ComponentState,
OptionalSlot,
OwnedRootProps,
PropsWithRefOf,
Slot,
} from '@fluentui-react-native/framework-base';
import type { ThemeState } from '@fluentui-react-native/design';

import type { Icon } from '../../primitives/icon/icon';
Expand All @@ -26,7 +33,7 @@ export type AvatarStateProps = {
size?: AvatarSize;
};

export type AvatarRootProps = OwnedRootProps<ViewProps>;
export type AvatarRootProps = OwnedRootProps<PropsWithRefOf<typeof View>>;

export type AvatarProps = AvatarStateProps & ComponentProps<AvatarSlots, AvatarRootProps>;

Expand Down
14 changes: 11 additions & 3 deletions packages/agentic/components/src/components/badge/badge.types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import type { StyleProp, Text, View, ViewProps, ViewStyle } from 'react-native';
import type { ComponentProps, ComponentState, OptionalSlot, OwnedRootProps, Slot, SlotProp } from '@fluentui-react-native/framework-base';
import type { StyleProp, Text, View, ViewStyle } from 'react-native';
import type {
ComponentProps,
ComponentState,
OptionalSlot,
OwnedRootProps,
PropsWithRefOf,
Slot,
SlotProp,
} from '@fluentui-react-native/framework-base';
import type { ThemeState } from '@fluentui-react-native/design';

import type { Icon } from '../../primitives/icon/icon';
Expand Down Expand Up @@ -59,7 +67,7 @@ type BadgeIconOnlyProps = BadgeCommonProps & {

export type BadgeStateProps = BadgeIconAndTextProps | BadgeIconOnlyProps;

export type BadgeExposedViewProps = OwnedRootProps<ViewProps, 'accessibilityRole' | 'focusable'>;
export type BadgeExposedViewProps = OwnedRootProps<PropsWithRefOf<typeof View>, 'accessibilityRole' | 'focusable'>;

export type BadgeProps = BadgeStateProps & ComponentProps<BadgeSlots, BadgeExposedViewProps>;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/** @jsxImportSource @fluentui-react-native/framework-base */
import * as React from 'react';
import { StyleSheet, View } from 'react-native';
import type { PressableProps, ViewStyle } from 'react-native';
import type { Pressable, PressableProps, ViewStyle } from 'react-native';

import { fireEvent, render } from '@testing-library/react-native';
import type { RenderResult } from '@testing-library/react-native';
Expand All @@ -23,6 +24,14 @@ function getRootStyle(component: RenderResult): ViewStyle {
}

describe('Button', () => {
it('forwards its ref prop to the native root', async () => {
const ref = React.createRef<React.ElementRef<typeof Pressable>>();

await renderButton({ content: 'Save', ref });

expect(ref.current).not.toBeNull();
});

it('reuses cached theme styles without recreating them for another button instance', async () => {
const createStyleSheet = jest.spyOn(StyleSheet, 'create');

Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import type { Pressable, PressableProps, StyleProp, Text, ViewStyle } from 'react-native';
import type { Pressable, StyleProp, Text, ViewStyle } from 'react-native';
import type {
Slot,
OptionalSlot,
OwnedRootProps,
ComponentProps,
ComponentState,
PressableState,
PropsWithRefOf,
} from '@fluentui-react-native/framework-base';
import type { ThemeState } from '@fluentui-react-native/design';
import type { Icon } from '../../primitives/icon/icon';
Expand Down Expand Up @@ -78,7 +79,7 @@ export type ButtonStateProps = {
* Props that are exposed from the underlying Pressable component at the top level. A button controls its
* own children and resolves styles from tokens, so those props are exposed separately.
*/
export type ButtonExposedPressableProps = OwnedRootProps<PressableProps>;
export type ButtonExposedPressableProps = OwnedRootProps<PropsWithRefOf<typeof Pressable>>;

/**
* Props for the Button component, including state props, slot props, and exposed Pressable props.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export function useButton_unstable(props: ButtonProps): ButtonState {
disabled = false,
icon: iconProp,
iconPosition = 'before',
ref: rootRef,
selected,
selectedIcon: selectedIconProp,
shape,
Expand Down Expand Up @@ -64,7 +65,7 @@ export function useButton_unstable(props: ButtonProps): ButtonState {
focusable: rest.focusable ?? !disabled,
};
const [pressableProps, pressableState] = usePressableState(nativeProps);
const root = useSlot(Pressable, pressableProps);
const root = useSlot(Pressable, { ...pressableProps, ref: rootRef });
const icon = useOptionalSlot(Icon, iconProp);
const selectedIcon = useOptionalSlot(Icon, selectedIconProp);
const content = useOptionalSlot(Text, contentProp);
Expand Down
16 changes: 16 additions & 0 deletions packages/agentic/components/src/components/card/card.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/** @jsxImportSource @fluentui-react-native/framework-base */
import * as React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import type { ViewStyle } from 'react-native';

Expand Down Expand Up @@ -27,6 +28,21 @@ describe('Card', () => {
jest.restoreAllMocks();
});

it('forwards its ref only to the structural native root', async () => {
const ref = jest.fn<void, [React.ElementRef<typeof View> | null]>();

await renderCard({
accessibilityLabel: 'Open report',
content: { children: <Text>Report</Text> },
onPress: jest.fn(),
ref,
testID: 'card-root',
});

expect(ref).toHaveBeenCalledTimes(1);
expect(ref).toHaveBeenCalledWith(expect.anything());
});

it('renders selection without changing it on press', async () => {
const onPress = jest.fn();
const component = await renderCard({ accessibilityLabel: 'Report', onPress, selected: false });
Expand Down
11 changes: 9 additions & 2 deletions packages/agentic/components/src/components/card/card.types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import type { Pressable, PressableProps, StyleProp, View, ViewStyle } from 'react-native';

import type { ComponentProps, ComponentState, OptionalSlot, OwnedRootProps, Slot } from '@fluentui-react-native/framework-base';
import type {
ComponentProps,
ComponentState,
OptionalSlot,
OwnedRootProps,
PropsWithRefOf,
Slot,
} from '@fluentui-react-native/framework-base';
import type { ThemeState } from '@fluentui-react-native/design';
import type { FocusVisualProps } from '../../primitives/focus-visual/focus-visual.types';

Expand Down Expand Up @@ -36,7 +43,7 @@ export type CardStateProps = {

export type CardExposedPressableProps = OwnedRootProps<PressableProps, 'accessibilityRole'> & {
children?: never;
};
} & Pick<PropsWithRefOf<typeof View>, 'ref'>;

export type CardProps = CardStateProps & ComponentProps<CardSlots, CardExposedPressableProps>;

Expand Down
14 changes: 9 additions & 5 deletions packages/agentic/components/src/components/card/useCard.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Pressable, View, useWindowDimensions } from 'react-native';
import type { PressableProps, ViewProps } from 'react-native';
import type { PressableProps } from 'react-native';

import { usePressableState, useOptionalSlot, useSlot } from '@fluentui-react-native/framework-base';
import { type PropsWithRefOf, usePressableState, useOptionalSlot, useSlot } from '@fluentui-react-native/framework-base';
import { useThemeState } from '@fluentui-react-native/design';

import type { CardProps, CardState } from './card.types';
Expand Down Expand Up @@ -37,6 +37,7 @@ export function useCard_unstable(props: CardProps): CardState {
onPressOut,
padding = 'default',
pressRetentionOffset,
ref: rootRef,
selected,
size = 'small',
style: userStyle,
Expand Down Expand Up @@ -83,8 +84,10 @@ export function useCard_unstable(props: CardProps): CardState {
unstable_pressDelay,
} as PressableProps);

const rootProps: ViewProps = isInteractive
? ({ ...rest, accessible: false, accessibilityState: { ...accessibilityState, disabled }, testID } as ViewProps)
const rootProps: PropsWithRefOf<typeof View> = isInteractive
? ({ ...rest, accessible: false, accessibilityState: { ...accessibilityState, disabled }, ref: rootRef, testID } as PropsWithRefOf<
typeof View
>)
: ({
...rest,
accessibilityLabel,
Expand All @@ -96,8 +99,9 @@ export function useCard_unstable(props: CardProps): CardState {
accessible: accessible ?? false,
accessibilityRole: (accessible ?? false) ? 'group' : undefined,
focusable: false,
ref: rootRef,
testID,
} as ViewProps);
} as PropsWithRefOf<typeof View>);

const root = useSlot(View, rootProps);
const overlay = useOptionalSlot(Pressable, isInteractive ? overlayProps : null);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import type { ColorValue, Pressable, PressableProps, StyleProp, Text, ViewStyle } from 'react-native';
import type { ColorValue, Pressable, StyleProp, Text, ViewStyle } from 'react-native';
import type {
ComponentProps,
ComponentState,
OptionalSlot,
OwnedRootProps,
PropsWithRefOf,
Slot,
PressableState,
} from '@fluentui-react-native/framework-base';
Expand Down Expand Up @@ -50,7 +51,7 @@ export type CheckboxStateProps = {
*/
export type CheckboxStatusDriverKeys = 'defaultStatus' | 'onStatusChange';

export type CheckboxRootProps = OwnedRootProps<PressableProps> & {
export type CheckboxRootProps = OwnedRootProps<PropsWithRefOf<typeof Pressable>> & {
children?: never;
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export function useCheckbox_unstable(props: CheckboxProps): CheckboxState {
label = 'Label',
onPress,
onStatusChange,
ref: rootRef,
secondaryText = 'Description',
showLabel = true,
showSecondaryText = false,
Expand Down Expand Up @@ -82,7 +83,7 @@ export function useCheckbox_unstable(props: CheckboxProps): CheckboxState {
const [focusVisibleProps, focusVisible] = useFocusVisible(nativeProps);
const [pressableProps, pressableState] = usePressableState(focusVisibleProps);

const root = useSlot(Pressable, pressableProps);
const root = useSlot(Pressable, { ...pressableProps, ref: rootRef });
const labelText = useOptionalSlot(Text, showLabel ? { accessible: false, children: label, testID: 'checkbox-label' } : null);
const secondaryTextSlot = useOptionalSlot(
Text,
Expand Down
Loading
Loading