Skip to content
Draft
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
6 changes: 3 additions & 3 deletions docs/docs/concepts/hybrid-views.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,16 +70,16 @@ To access the actual underlying object, you can use the `hybridRef`:
function App() {
return (
<Camera
hybridRef={callback((ref) => {
hybridRef={(ref) => {
console.log(ref.name) // <-- HybridCamera
const image = ref.takePhoto()
})}
}}
/>
)
}
```

> Note: If you're wondering about the `callback(...)` syntax, see ["Callbacks have to be wrapped"](../guides/view-components#callbacks-have-to-be-wrapped).
> Note: On react-native 0.78 - 0.80, this has to be wrapped in `callback(...)`, see ["Callbacks"](../guides/view-components#callbacks).

## Full Guides

Expand Down
32 changes: 20 additions & 12 deletions docs/docs/guides/view-components.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ The key difference to a Fabric view is that it uses Nitro for prop parsing, whic

:::note
Nitro Views require **react-native 0.78.0** or higher, and require the new architecture.
On **react-native 0.81.0** or higher, callbacks can be passed to Nitro Views directly (see ["Callbacks"](#callbacks)).
:::

## Create a Nitro View
Expand Down Expand Up @@ -235,12 +236,9 @@ To batch prop changes, you can override `beforeUpdate()` and `afterUpdate()` in
</TabItem>
</Tabs>

### Callbacks have to be wrapped
### Callbacks

Whereas Nitro allows passing JS functions to native code directly, React Native core doesn't allow that. Instead, functions are wrapped in an event listener registry, and a simple boolean is passed to the native side.
Unfortunately React Native's renderer does not yet allow changing this behaviour, so functions cannot be passed directly to Nitro Views. As a workaround, Nitro requires you to wrap each function in an object, which bypasses React Native's conversion.

To simplify this, Nitro exposes the `callback(...)` method:
Nitro passes JS functions to native code directly, so callbacks are just regular props:

```tsx
export interface CameraProps extends HybridViewProps {
Expand All @@ -249,15 +247,25 @@ export interface CameraProps extends HybridViewProps {
export type CameraView = HybridView<CameraProps>

function App() {
// diff-remove
return <Camera onCaptured={(i) => console.log(i)} />
// diff-add
}
```

#### react-native 0.78 - 0.80: `callback(...)`

Historically, React Native core did not allow this. Instead, functions were wrapped in an event listener registry, and a simple boolean was passed to the native side.
Since [react-native 0.81](https://github.com/facebook/react-native/pull/48777) a View Config can opt out of that conversion, which Nitro does - so functions arrive on the native side unchanged.

On **react-native 0.78 - 0.80** you still need to wrap every function in an object to bypass React Native's conversion. Nitro exposes the `callback(...)` method for this:

```tsx
function App() {
return <Camera onCaptured={callback((i) => console.log(i))} />
}
```

:::info
We are working on a fix here: [facebook/react #32119](https://github.com/facebook/react/pull/32119)
:::warning
`callback(...)` is deprecated. On react-native 0.81 and above it is a no-op, so upgrade to react-native 0.81 or newer and remove all `callback(...)` calls.
:::

### Recycling
Expand Down Expand Up @@ -325,14 +333,14 @@ To call the function, you would need to get a reference to the `HybridObject` fi
function App() {
return (
<Camera
hybridRef={callback((ref) => {
hybridRef={(ref) => {
const image = ref.takePhoto()
})}
}}
/>
)
}
```

> Note: If you're wondering about the `callback(...)` syntax, see ["Callbacks have to be wrapped"](#callbacks-have-to-be-wrapped).
> Note: On react-native 0.78 - 0.80, this has to be wrapped in `callback(...)`, see ["Callbacks"](#callbacks).

The `ref` from within `hybridRef`'s callback is pointing to the `HybridObject` directly - you can also pass this around freely.
117 changes: 84 additions & 33 deletions example/__tests__/nitro.views.harness.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -177,11 +177,11 @@ describe('TestView', () => {
<TestView
testID="test-view-initial"
style={INITIAL_SIZE}
hybridRef={callback((view) => viewRef.resolve(view))}
hybridRef={(view) => viewRef.resolve(view)}
isBlue={true}
hasBeenCalled={false}
colorScheme="dark"
someCallback={callback(onSomeCallback)}
someCallback={onSomeCallback}
onLayout={({ nativeEvent }) => layout.resolve(nativeEvent.layout)}
/>,
{ timeout: RENDER_TIMEOUT }
Expand Down Expand Up @@ -209,18 +209,43 @@ describe('TestView', () => {
expect(onSomeCallback).toHaveBeenCalledTimes(1)
})

it('still accepts callbacks wrapped in the deprecated `callback(...)`', async () => {
const viewRef = deferred<TestViewRef>()
const callbackFinished = deferred<void>()
const onSomeCallback = fn(() => callbackFinished.resolve(undefined))

await render(
<TestView
testID="test-view-wrapped-callback"
style={INITIAL_SIZE}
hybridRef={callback((view: TestViewRef) => viewRef.resolve(view))}
isBlue={true}
hasBeenCalled={false}
colorScheme="dark"
someCallback={callback(onSomeCallback)}
/>,
{ timeout: RENDER_TIMEOUT }
)

const mountedView = await viewRef.promise
mountedView.someMethod()
await callbackFinished.promise
expect(mountedView.hasBeenCalled).toBe(true)
expect(onSomeCallback).toHaveBeenCalledTimes(1)
})

it('updates every prop, changes pixels, and resizes the same native view', async () => {
const initialRef = deferred<TestViewRef>()
const initialCallback = fn()
const renderResult = await render(
<TestView
testID="test-view-updates"
style={INITIAL_SIZE}
hybridRef={callback((view) => initialRef.resolve(view))}
hybridRef={(view) => initialRef.resolve(view)}
isBlue={true}
hasBeenCalled={false}
colorScheme="dark"
someCallback={callback(initialCallback)}
someCallback={initialCallback}
/>,
{ timeout: RENDER_TIMEOUT }
)
Expand All @@ -233,10 +258,7 @@ describe('TestView', () => {
const updatedRef = deferred<TestViewRef>()
const updatedCallbackFinished = deferred<void>()
const updatedCallback = fn(() => updatedCallbackFinished.resolve(undefined))
const updatedHybridRef = callback((view: TestViewRef) =>
updatedRef.resolve(view)
)
const updatedSomeCallback = callback(updatedCallback)
const updatedHybridRef = (view: TestViewRef) => updatedRef.resolve(view)

await renderResult.rerender(
<TestView
Expand All @@ -246,7 +268,7 @@ describe('TestView', () => {
isBlue={false}
hasBeenCalled={true}
colorScheme="light"
someCallback={updatedSomeCallback}
someCallback={updatedCallback}
/>
)

Expand Down Expand Up @@ -277,7 +299,7 @@ describe('TestView', () => {
isBlue={false}
hasBeenCalled={true}
colorScheme="light"
someCallback={updatedSomeCallback}
someCallback={updatedCallback}
onLayout={({ nativeEvent }) =>
resizedLayout.resolve(nativeEvent.layout)
}
Expand All @@ -296,12 +318,45 @@ describe('TestView', () => {
expectRed(resizedCapture.pixelCoverage)
})

it('preserves an omitted native default while applying another prop', async () => {
it('keeps updating props after an optional callback prop is removed', async () => {
const viewRef = deferred<TestViewRef>()
const stableHybridRef = callback((view: TestViewRef) =>
viewRef.resolve(view)
const stableSomeCallback = fn()
const renderResult = await render(
<TestView
testID="test-view-removed-callback"
style={INITIAL_SIZE}
hybridRef={(view) => viewRef.resolve(view)}
isBlue={true}
hasBeenCalled={false}
colorScheme="dark"
someCallback={stableSomeCallback}
/>,
{ timeout: RENDER_TIMEOUT }
)

const view = await viewRef.promise
expect(view.isBlue).toBe(true)

// `hybridRef` is now `null` in the props payload - it must not throw.
await renderResult.rerender(
<TestView
testID="test-view-removed-callback"
style={INITIAL_SIZE}
isBlue={false}
hasBeenCalled={false}
colorScheme="light"
someCallback={stableSomeCallback}
/>
)
const stableSomeCallback = callback(fn())

expect(view.isBlue).toBe(false)
expect(view.colorScheme).toBe('light')
})

it('preserves an omitted native default while applying another prop', async () => {
const viewRef = deferred<TestViewRef>()
const stableHybridRef = (view: TestViewRef) => viewRef.resolve(view)
const stableSomeCallback = fn()
const renderResult = await render(
<TestView
testID="test-view-native-default"
Expand Down Expand Up @@ -341,10 +396,8 @@ describe('TestView', () => {

it('only calls native setters for changed Nitro props', async () => {
const viewRef = deferred<TestViewRef>()
const stableHybridRef = callback((view: TestViewRef) =>
viewRef.resolve(view)
)
const stableSomeCallback = callback(fn())
const stableHybridRef = (view: TestViewRef) => viewRef.resolve(view)
const stableSomeCallback = fn()
const renderResult = await render(
<TestView
testID="test-view-setter-counts"
Expand Down Expand Up @@ -429,11 +482,11 @@ describe('TestView', () => {
<TestView
testID="test-view-lifecycle"
style={INITIAL_SIZE}
hybridRef={callback((view) => firstRef.resolve(view))}
hybridRef={(view) => firstRef.resolve(view)}
isBlue={true}
hasBeenCalled={false}
colorScheme="dark"
someCallback={callback(fn())}
someCallback={fn(() => {})}
/>,
{ timeout: RENDER_TIMEOUT }
)
Expand All @@ -453,11 +506,11 @@ describe('TestView', () => {
<TestView
testID="test-view-lifecycle"
style={INITIAL_SIZE}
hybridRef={callback((view) => secondRef.resolve(view))}
hybridRef={(view) => secondRef.resolve(view)}
isBlue={false}
hasBeenCalled={true}
colorScheme="light"
someCallback={callback(fn())}
someCallback={fn(() => {})}
/>,
{ timeout: RENDER_TIMEOUT }
)
Expand All @@ -478,12 +531,10 @@ describe('multiple RecyclableTestViews', () => {
it('keeps instances isolated while one is updated and recycled', async () => {
const firstRef = deferred<RecyclableTestViewRef>()
const secondRef = deferred<RecyclableTestViewRef>()
const firstHybridRef = callback((view: RecyclableTestViewRef) =>
const firstHybridRef = (view: RecyclableTestViewRef) =>
firstRef.resolve(view)
)
const secondHybridRef = callback((view: RecyclableTestViewRef) =>
const secondHybridRef = (view: RecyclableTestViewRef) =>
secondRef.resolve(view)
)

const renderResult = await render(
<View style={{ flexDirection: 'row' }}>
Expand Down Expand Up @@ -603,7 +654,7 @@ describe('multiple RecyclableTestViews', () => {
key="first"
testID="isolated-recyclable-view-first"
style={INITIAL_SIZE}
hybridRef={callback((view) => remountedFirstRef.resolve(view))}
hybridRef={(view) => remountedFirstRef.resolve(view)}
isBlue={true}
/>
<RecyclableTestView
Expand Down Expand Up @@ -654,7 +705,7 @@ describe('RecyclableTestView', () => {
<RecyclableTestView
testID="recyclable-view-updates"
style={INITIAL_SIZE}
hybridRef={callback((view) => initialRef.resolve(view))}
hybridRef={(view) => initialRef.resolve(view)}
isBlue={false}
onLayout={({ nativeEvent }) =>
initialLayout.resolve(nativeEvent.layout)
Expand All @@ -677,9 +728,9 @@ describe('RecyclableTestView', () => {
expectRed(redCapture.pixelCoverage)

const updatedRef = deferred<RecyclableTestViewRef>()
const updatedHybridRef = callback((view: RecyclableTestViewRef) =>
const updatedHybridRef = (view: RecyclableTestViewRef) =>
updatedRef.resolve(view)
)

await renderResult.rerender(
<RecyclableTestView
testID="recyclable-view-updates"
Expand Down Expand Up @@ -733,7 +784,7 @@ describe('RecyclableTestView', () => {
<RecyclableTestView
testID="recyclable-view-lifecycle"
style={INITIAL_SIZE}
hybridRef={callback((view) => firstRef.resolve(view))}
hybridRef={(view) => firstRef.resolve(view)}
isBlue={true}
/>,
{ timeout: RENDER_TIMEOUT }
Expand Down Expand Up @@ -766,7 +817,7 @@ describe('RecyclableTestView', () => {
<RecyclableTestView
testID="recyclable-view-lifecycle"
style={RESIZED_SIZE}
hybridRef={callback((view) => secondRef.resolve(view))}
hybridRef={(view) => secondRef.resolve(view)}
isBlue={false}
nativeDefaultValue={0}
onLayout={({ nativeEvent }) => secondLayout.resolve(nativeEvent.layout)}
Expand Down Expand Up @@ -815,7 +866,7 @@ describe('RecyclableTestView', () => {
<RecyclableTestView
testID="recyclable-view-lifecycle"
style={INITIAL_SIZE}
hybridRef={callback((view) => thirdRef.resolve(view))}
hybridRef={(view) => thirdRef.resolve(view)}
isBlue={true}
onLayout={({ nativeEvent }) => thirdLayout.resolve(nativeEvent.layout)}
/>
Expand Down
8 changes: 4 additions & 4 deletions example/src/screens/ViewScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as React from 'react'

import { StyleSheet, View, Text, Button, Platform } from 'react-native'
import { callback, NitroModules } from 'react-native-nitro-modules'
import { NitroModules } from 'react-native-nitro-modules'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useColors } from '../useColors'
import {
Expand All @@ -27,15 +27,15 @@ export function ViewScreenImpl() {
return (
<TestView
key={i}
hybridRef={callback((ref) => {
hybridRef={(ref) => {
console.log(`Ref initialized!`)
ref.someMethod()
const isBlue = HybridTestObjectSwiftKotlin.getIsViewBlue(ref)
console.log(`Is View blue: ${isBlue}`)
})}
}}
style={styles.view}
isBlue={i % 2 === 0}
someCallback={callback(() => console.log(`Callback called!`))}
someCallback={() => console.log(`Callback called!`)}
colorScheme="dark"
hasBeenCalled={false}
onTouchEnd={() => {
Expand Down
Loading
Loading