Skip to content
Open
Show file tree
Hide file tree
Changes from 46 commits
Commits
Show all changes
82 commits
Select commit Hold shift + click to select a range
9bcc979
Prepare JS component and native spec
t0maboro Apr 24, 2026
95e8711
Temporarly update app
t0maboro Apr 24, 2026
6f3db4a
Prepare native component
t0maboro Apr 24, 2026
7a8bd70
Add yoga layout
t0maboro Apr 24, 2026
314ab39
Attach touch handler to modal hierarchy
t0maboro Apr 24, 2026
67a9cdd
Reset ShadowNode size, when dismissed
t0maboro Apr 24, 2026
fb978ca
Reset modal wrapper size when controller disappears
t0maboro Apr 24, 2026
46ed5d6
Add native support for sheet detents
t0maboro Apr 24, 2026
84e3268
Fix crash when detaching touch handler from modal
t0maboro Apr 24, 2026
118f949
Remove workaround with content subviews layout
t0maboro Apr 24, 2026
f2cfaf9
Reset shadow node state AFTER completing dismissal from JS
t0maboro Apr 24, 2026
a93719a
Add detents to example
t0maboro Apr 24, 2026
a864044
Drop Modal prefix from names
t0maboro Apr 28, 2026
e980b58
Add modals directory
t0maboro Apr 28, 2026
ddc3c4b
Move app to dedicated SFT dir
t0maboro Apr 28, 2026
a32f25a
Cleanup
t0maboro Apr 28, 2026
b977f45
Cleanup cpp
t0maboro Apr 28, 2026
afcef8c
Cleanup JS component
t0maboro Apr 28, 2026
b70f4df
Add CIT for nesting stack v5
t0maboro Apr 28, 2026
3f11bbd
Update test & add scenario
t0maboro Apr 29, 2026
10dfdfc
Prepare SFT
t0maboro Apr 29, 2026
db637a3
Rename
t0maboro Apr 29, 2026
0ed38e8
Align init logic with other components
t0maboro Apr 29, 2026
3538ec9
Add helper
t0maboro Apr 29, 2026
1c1dc96
Move EventEmitter to separate class
t0maboro Apr 29, 2026
f76775d
Move touchhandler setup to helper
t0maboro Apr 29, 2026
988a970
Update detents logic
t0maboro Apr 29, 2026
99702c7
Add fallback for detents on iOS 15
t0maboro Apr 29, 2026
43288c2
Cleanup
t0maboro Apr 29, 2026
ca11eff
Use clearColor
t0maboro Apr 29, 2026
668f898
Optimize
t0maboro Apr 29, 2026
fb805e4
Check for sorting detents
t0maboro Apr 29, 2026
62d1023
Exclude component from android, until we implement it
t0maboro Apr 29, 2026
239ad71
Add fallbacks on Android and Web
t0maboro Apr 29, 2026
447d437
Add nullable
t0maboro Apr 29, 2026
c10909b
Correction
t0maboro Apr 29, 2026
b988e9f
Add missing super call
t0maboro Apr 29, 2026
ecd371a
Add range validation
t0maboro Apr 29, 2026
682998d
Add logs
t0maboro Apr 29, 2026
7a6caec
Run formatter
t0maboro Apr 29, 2026
7fdd958
Update imports
t0maboro Apr 29, 2026
e22a373
Add stubs
t0maboro Apr 29, 2026
7f2586d
Update doc
t0maboro Apr 29, 2026
7a4e047
Handle NaN and inf as invalid detents
t0maboro Apr 29, 2026
debc063
Track frame changes instead of size changes
t0maboro Apr 29, 2026
a791d94
Drop readonly
t0maboro Apr 29, 2026
6d8c63c
Drop non-negative size condition
t0maboro Apr 30, 2026
99bfdc4
Don't use ifndef
t0maboro Apr 30, 2026
d613f60
Remove Shared alias
t0maboro Apr 30, 2026
c7e29af
Use new instead of alloc-init
t0maboro Apr 30, 2026
d1c50fb
Add Host to native part and spec
t0maboro Apr 30, 2026
5354e50
Move presentation source search to utils
t0maboro Apr 30, 2026
3f1ae20
Async update for cleanup
t0maboro Apr 30, 2026
96e8499
Add comment
t0maboro Apr 30, 2026
a46e7fd
Remove filtering out self from provider
t0maboro Apr 30, 2026
071d11d
refactor sheetControllerViewDidLayoutSubviews
t0maboro Apr 30, 2026
367e4a4
Add ShadowStateProxy
t0maboro Apr 30, 2026
8ca5864
Remove reactSubviews
t0maboro Apr 30, 2026
b12b1bc
Move section
t0maboro Apr 30, 2026
f5217d4
Add assertion
t0maboro Apr 30, 2026
8e649fc
Preallocate array
t0maboro Apr 30, 2026
7903fe9
Add compile-time check
t0maboro Apr 30, 2026
e83be5f
Fallback to large detent when detens were not provided
t0maboro Apr 30, 2026
c749d12
Use iterator
t0maboro Apr 30, 2026
e597e45
Remove isinf
t0maboro Apr 30, 2026
fb5940d
Remove respondsToSelector calls
t0maboro Apr 30, 2026
c9ce145
Add section
t0maboro Apr 30, 2026
edd2491
Add ContentView
t0maboro Apr 30, 2026
c1a04de
Use warnOnce
t0maboro Apr 30, 2026
a9355c9
Drop ViewProps from types
t0maboro Apr 30, 2026
2788160
Rephrase
t0maboro Apr 30, 2026
c29d127
Improve docs
t0maboro Apr 30, 2026
ec52bca
Add nullability qualifiers
t0maboro Apr 30, 2026
8ef3037
Add comment
t0maboro Apr 30, 2026
7ea4db6
Move search only to presenting branch
t0maboro Apr 30, 2026
fe9d576
Temporary rename to onNativeDismiss
t0maboro Apr 30, 2026
0912a24
rename
t0maboro Apr 30, 2026
cddfd9e
Change key
t0maboro Apr 30, 2026
867c6a4
Move to ios dir
t0maboro Apr 30, 2026
3569432
Add step
t0maboro Apr 30, 2026
43a4f23
Rename key
t0maboro Apr 30, 2026
3843b49
Calculate contentOriginOffset as a vector from Host to ContentView
t0maboro Apr 30, 2026
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
12 changes: 12 additions & 0 deletions apps/src/tests/component-integration-tests/form-sheet/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { ScenarioGroup } from '@apps/tests/shared/helpers';
import TestFormSheetWithNestedStackV5 from './test-form-sheet-with-nested-stack-v5';

const scenarios = { TestFormSheetWithNestedStackV5 };

const FormSheetScenarioGroup: ScenarioGroup<keyof typeof scenarios> = {
name: 'FormSheet Integration Tests',
details: 'Test interaction between FormSheet and different components',
scenarios,
};

export default FormSheetScenarioGroup;
Comment thread
LKuchno marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import React, { useState } from 'react';
import { Button, StyleSheet, Text, View } from 'react-native';
import { FormSheet } from 'react-native-screens/experimental';
import type { ScenarioDescription } from '@apps/tests/shared/helpers';
import { createScenario } from '@apps/tests/shared/helpers';
import { StackContainer } from '@apps/shared/gamma/containers/stack';
import { CenteredLayoutView } from '@apps/shared/CenteredLayoutView';
import { Colors } from '@apps/shared/styling';
import { StackNavigationButtons } from '@apps/tests/shared/components/stack-v5/StackNavigationButtons';

const scenarioDescription: ScenarioDescription = {
name: 'FormSheet with Nested Stack v5',
key: 'test-formsheet-nested-stack-v5',
Copy link
Copy Markdown
Collaborator

@LKuchno LKuchno Apr 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this is a CIT scenario, the directory name should look slightly different. I would propose: test-form-sheet-in-stack-v5-ios. The platform must be included as this test is iOS-only.
@kkafar , what do you think about the naming? Since no additional props are being tested, I wouldn't include the <component-a>-<component-b>-<test-case-name> part here, as the test case is basically a <component-a>-in-<component-b> check.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

details: 'Test nesting Stack v5 inside a FormSheet',
platforms: ['ios'],
};

export function App() {
const [isOpen, setIsOpen] = useState(false);

return (
<View style={styles.container}>
<Text style={styles.title}>FormSheet with nested StackV5</Text>
<Button
title="Open FormSheet"
color={Colors.primary}
onPress={() => setIsOpen(true)}
/>
<FormSheet
isOpen={isOpen}
onDismiss={() => setIsOpen(false)}
detents={[0.6, 1.0]}>
<View style={styles.sheetContent}>
<StackSetup />
</View>
</FormSheet>
</View>
);
}

function StackSetup() {
return (
<StackContainer
routeConfigs={[
{
name: 'Home',
Component: HomeScreen,
options: {},
},
{
name: 'A',
Component: AScreen,
options: {
headerConfig: { title: 'A' },
},
},
]}
/>
);
}

function HomeScreen() {
return (
<CenteredLayoutView style={{ backgroundColor: Colors.BlueLight40 }}>
<Text style={styles.screenText}>Home Screen</Text>
<StackNavigationButtons isPopEnabled={false} routeNames={['A']} />
</CenteredLayoutView>
);
}

function AScreen() {
return (
<CenteredLayoutView style={{ backgroundColor: Colors.YellowLight40 }}>
<Text style={styles.screenText}>Screen A</Text>
<StackNavigationButtons isPopEnabled={true} routeNames={['A']} />
</CenteredLayoutView>
);
}

const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: Colors.offBackground,
},
title: {
fontSize: 20,
fontWeight: 'bold',
marginBottom: 20,
color: Colors.text,
},
sheetContent: {
flex: 1,
backgroundColor: Colors.background,
},
screenText: {
color: Colors.text,
fontSize: 20,
fontWeight: 'bold',
textAlign: 'center',
marginBottom: 10,
},
});

export default createScenario(App, scenarioDescription);
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Test Scenario: FormSheet with Nested Stack v5

## Details

**Description:** Verify the layout and state persistence of a `StackContainer` nested within a `FormSheet`. This test ensures that the Stack layout correctly fills the `FormSheet` container, that content remains properly centered, that the layout smoothly adapts when the FormSheet height changes, and that the Stack's navigation state is preserved when the sheet is dismissed and reopened.

**OS test creation version:** iOS: 18.6 and 26.4

## E2E test

Other: Planned, but will be implemented separately.

## Prerequisites

- iOS device/simulator

## Steps

### Baseline

1. Launch the app and navigate to the **FormSheet with Nested Stack v5** screen.

- [ ] Expected: Content with the button "Open FormSheet" is shown

---

### Initialization & Layout Verification

2. Tap the "Open FormSheet" button.

- [ ] Expected: The FormSheet opens at the initial lower detent (0.6). The "Home Screen" text is visible and centered within the sheet. The light blue background completely covers the FormSheet content area.


3. Tap the "Push A" button to push Screen A.

- [ ] Expected: The stack navigates to "Screen A". The "Screen A" text is centered. The light yellow background completely covers the FormSheet content area.

---

### Detent Adaptation

4. Grab the top edge of the FormSheet and swipe up to expand it to the maximum detent (1.0).

- [ ] Expected: The FormSheet expands to take up the full screen height. The layout adapts dynamically - the light yellow background stretches to cover the new full height, and the "Screen A" text dynamically re-centers itself within the newly expanded view area.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I think that we should clarify what we mean by "full screen height" - it's technically not full screen height but full screen height without the top inset. Without video I would think that the first option is expected.

Applies to second scenario as well.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


---

### State Persistence

5. Swipe down on the FormSheet to dismiss it, then tap the "Open FormSheet" button again.

- [ ] Expected: The FormSheet re-opens at the initial lower detent (0.6). The stack's navigation state has been kept - the sheet immediately displays "Screen A" (with the yellow background and centered text) rather than resetting back to the Home Screen.
Comment thread
LKuchno marked this conversation as resolved.
2 changes: 2 additions & 0 deletions apps/src/tests/component-integration-tests/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@ import { ScenarioButton } from '@apps/tests/shared/ScenarioButton';

import OrientationScenarioGroup from './orientation';
import ScrollViewScenarioGroup from './scroll-view';
import FormSheetScenarioGroup from './form-sheet';
import ScenarioSelectionScreen from '@apps/tests/shared/ScenarioScreen';

export const COMPONENT_SCENARIOS = {
Orientation: OrientationScenarioGroup,
ScrollView: ScrollViewScenarioGroup,
FormSheet: FormSheetScenarioGroup,
} as const;

type ParamsList = { [k: keyof typeof COMPONENT_SCENARIOS]: undefined } & {
Expand Down
14 changes: 14 additions & 0 deletions apps/src/tests/single-feature-tests/form-sheet/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { ScenarioGroup } from '@apps/tests/shared/helpers';
import TestFormSheetBase from './test-form-sheet-base';

const scenarios = {
TestFormSheetBase,
};

const FormSheetScenarioGroup: ScenarioGroup<keyof typeof scenarios> = {
name: 'FormSheet',
details: 'Single feature tests for FormSheets',
scenarios,
};

export default FormSheetScenarioGroup;
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import React, { useState } from 'react';
import { Button, StyleSheet, Text, View } from 'react-native';
import { FormSheet } from 'react-native-screens/experimental';
import type { ScenarioDescription } from '@apps/tests/shared/helpers';
import { createScenario } from '@apps/tests/shared/helpers';
import { Colors } from '@apps/shared/styling';

const scenarioDescription: ScenarioDescription = {
name: 'Basic functionality',
key: 'test-formsheet-base',
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Following naming convention for directories I would set key to 'test-form-sheet-base-ios'

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

details: 'Allows to test the basic functionality of FormSheet component.',
platforms: ['ios'],
};

export function App() {
const [isOpen, setIsOpen] = useState(false);

return (
<View style={styles.container}>
<Text style={styles.title}>FormSheet Test</Text>
<Button
title="Open FormSheet"
color={Colors.primary}
onPress={() => setIsOpen(true)}
/>
<FormSheet
isOpen={isOpen}
onDismiss={() => setIsOpen(false)}
detents={[0.6, 1.0]}>
<View style={styles.sheetContent}>
<Text style={styles.sheetTitle}>FormSheet content</Text>
<View style={styles.spacing} />
<Button
title="Dismiss from JS"
color={Colors.primary}
onPress={() => setIsOpen(false)}
/>
</View>
</FormSheet>
</View>
);
}

const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: Colors.offBackground,
},
title: {
fontSize: 20,
fontWeight: 'bold',
marginBottom: 20,
color: Colors.text,
},
sheetContent: {
flex: 1,
backgroundColor: Colors.background,
padding: 24,
justifyContent: 'center',
alignItems: 'center',
},
sheetTitle: {
fontSize: 22,
fontWeight: '600',
marginBottom: 12,
color: Colors.text,
},
spacing: {
height: 32,
},
});

export default createScenario(App, scenarioDescription);
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Test Scenario: Basic functionality

## Details

**Description:** Verify the core functionality and layout stability of the `FormSheet` component. This test ensures that the FormSheet opens correctly, that its internal content is properly centered, and that the content dynamically maintains its centered alignment when the user manually adjusts the sheet's height between different detents.

**OS test creation version:** iOS: 18.6 and 26.4

## E2E test

Other: Planned, but will be implemented separately.

## Prerequisites

- iOS device/simulator

## Steps

### Baseline

1. Launch the app and navigate to the **Basic functionality** screen.

- [ ] Expected: Content with the button "Open FormSheet" is shown

---

### Initialization & Layout Verification

2. Tap the "Open FormSheet" button.

- [ ] Expected: The FormSheet opens at the initial lower detent (0.6). The "FormSheet content" text and the "Dismiss from JS" button are visible and perfectly centered both vertically and horizontally within the sheet.

---

### Detent Adaptation

3. Grab the top edge of the FormSheet and swipe up to expand it to the maximum detent (1.0).

- [ ] Expected: The FormSheet expands to take up the full screen height. The internal layout adapts dynamically, and the "FormSheet content" text along with the "Dismiss from JS" button remain perfectly centered within the newly expanded view area.

---

### Dismissal Verification

4. Tap the "Dismiss from JS" button (or swipe down completely).

- [ ] Expected: The FormSheet dismisses smoothly and returns the user to the underlying main screen. Pressables on the main screen are working.
2 changes: 2 additions & 0 deletions apps/src/tests/single-feature-tests/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import TabsScenarioGroup from './tabs';
import SplitScenarioGroup from './split';
import StackV5ScenarioGroup from './stack-v5';
import StackV4ScenarioGroup from './stack-v4';
import FormSheetScenarioGroup from './form-sheet';
import { ScenarioButton } from '@apps/tests/shared/ScenarioButton';
import ScenarioSelectionScreen from '@apps/tests/shared/ScenarioScreen';

Expand All @@ -18,6 +19,7 @@ export const COMPONENT_SCENARIOS = {
Split: SplitScenarioGroup,
StackV5: StackV5ScenarioGroup,
StackV4: StackV4ScenarioGroup,
FormSheet: FormSheetScenarioGroup,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to have a flat hierarchy with different kinds of modals or some Modal scenario group first? Obviously this might be changed later when we add other types of modals.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

aligned with @kkafar and @LKuchno that we prefer keeping the flat hierarchy for tests, if we feel that it will be problematic for some reason, we'll rethink the approach then

} as const;

type ParamsList = { [k: keyof typeof COMPONENT_SCENARIOS]: undefined } & {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
#pragma once

#ifndef ANDROID
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's okay. I recently started preferring #if !defined(ANDROID) as more readable. Up to you, I don't mind either. Just showing an alternative.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


#include <react/debug/react_native_assert.h>
#include <react/renderer/core/ConcreteComponentDescriptor.h>
#include "RNSFormSheetShadowNode.h"

namespace facebook::react {

class RNSFormSheetComponentDescriptor final
: public ConcreteComponentDescriptor<RNSFormSheetShadowNode> {
public:
using ConcreteComponentDescriptor::ConcreteComponentDescriptor;

void adopt(ShadowNode &shadowNode) const override {
react_native_assert(dynamic_cast<RNSFormSheetShadowNode *>(&shadowNode));
auto &concreteShadowNode =
static_cast<RNSFormSheetShadowNode &>(shadowNode);

react_native_assert(
dynamic_cast<YogaLayoutableShadowNode *>(&concreteShadowNode));
auto &layoutableShadowNode =
static_cast<YogaLayoutableShadowNode &>(concreteShadowNode);

auto state =
std::static_pointer_cast<const RNSFormSheetShadowNode::ConcreteState>(
shadowNode.getState());

auto stateData = state->getData();

if (stateData.frameSize.width >= 0 && stateData.frameSize.height >= 0) {
layoutableShadowNode.setSize(
Size{stateData.frameSize.width, stateData.frameSize.height});
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this ever not true?

I need to read the code further leaving notes for myself.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Usually what I've done recently is to initialise the frameSize with special empty value and then compare against it here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


ConcreteComponentDescriptor::adopt(shadowNode);
}
};

} // namespace facebook::react

#endif // ANDROID
Loading
Loading