Skip to content
Open
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
4 changes: 3 additions & 1 deletion .github/renovate.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@
"labels": ["dependencies"],
"osvVulnerabilityAlerts": true,
"vulnerabilityAlerts": {
"addLabels": ["security"]
"description": "`enabled` is forced onto alert matches, so packages a rule sets `enabled: false` (the Expo-owned group) still get vulnerability PRs.",
"addLabels": ["security"],
"enabled": true
},
"packageRules": [
{
Expand Down
3 changes: 0 additions & 3 deletions app/config/setup.shared.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { afterAll, afterEach, beforeEach, jest } from '@jest/globals';
import { cleanup } from '@testing-library/react-native';
import type React from 'react';
import { server } from '@/test-utils/server';

Expand Down Expand Up @@ -30,11 +29,9 @@ beforeEach(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => {
server.resetHandlers();
server.close();
cleanup();
jest.clearAllTimers();
});
afterAll(() => {
cleanup();
jest.clearAllTimers();
jest.useRealTimers();
server.close();
Expand Down
17 changes: 11 additions & 6 deletions app/src/components/base/AppButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,17 @@ export function AppButton({
...rest
}: AppButtonProps) {
const { colors } = useAppTheme();
// Bare RN text nodes must live inside <Text>, and an interpolated label
// ("Select all ({count})") arrives as an array of them, not a single string.
// Wrap each primitive and leave element children alone.
const renderedChildren = Children.map(children, (child) =>
typeof child === 'string' || typeof child === 'number' ? <Text>{child}</Text> : child,
);
// Bare RN text nodes must live inside <Text>. An interpolated label
// ("Select all ({count})") arrives as an array of primitives; wrap the whole
// run in one <Text> so it stays a single node (no flex gaps, one a11y label).
const parts = Children.toArray(children);
const isPrimitive = (c: unknown) => typeof c === 'string' || typeof c === 'number';
const renderedChildren =
parts.length > 0 && parts.every(isPrimitive) ? (
<Text>{children}</Text>
) : (
Children.map(children, (child) => (isPrimitive(child) ? <Text>{child}</Text> : child))
);
return (
<Button
variant={VARIANT_MAP[variant]}
Expand Down
17 changes: 17 additions & 0 deletions app/src/components/base/__tests__/AppButton.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react-native';
import { AppButton } from '@/components/base/AppButton';
import { Text } from '@/components/base/ui/text';

// react-native's own Platform.select (Platform.ios.js) hardcodes 'ios'/'native'
// key checks and ignores Platform.OS, and the vendored ui/button.tsx computes
Expand Down Expand Up @@ -113,3 +114,19 @@ test('has web hover, cursor, and focus-visible affordances', async () => {
expect(className).toEqual(expect.stringContaining('hover:'));
expect(className).toEqual(expect.stringContaining('focus-visible:'));
});

test('interpolated label renders as one Text node', async () => {
const count = 2;
await render(<AppButton onPress={() => {}}>Select all ({count})</AppButton>);
expect(screen.getByText('Select all (2)')).toBeTruthy();
expect(screen.container.queryAll((el) => el.type === 'Text')).toHaveLength(1);
});

test('element children are left unwrapped', async () => {
await render(
<AppButton onPress={() => {}}>
<Text>Custom</Text>
</AppButton>,
);
expect(screen.container.queryAll((el) => el.type === 'Text')).toHaveLength(1);
});
12 changes: 2 additions & 10 deletions app/src/test-utils/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ type HostElement = ReturnType<typeof screen.getByTestId>;
* v14 renders host elements only, so the `UNSAFE_*ByType` / `UNSAFE_*ByProps`
* queries that could return composite components are gone. Where a test asserts
* on something with no accessible handle — an icon's stroke width, a list's
* paging threshold — these walk the rendered tree by host name instead. React
* paging threshold — these query the rendered tree by host name instead. React
* Native's host names are not the component names: a `FlatList` is
* `RCTScrollView`, an `Svg` is `RNSVGSvgView`, a `View` and a `TextInput` keep
* theirs.
Expand All @@ -17,16 +17,8 @@ type HostElement = ReturnType<typeof screen.getByTestId>;
* one exists; reach for these only when the assertion is about a prop the user
* cannot see.
*/
function walk(node: unknown, out: HostElement[]): HostElement[] {
const el = node as { type?: unknown; children?: unknown[] } | null;
if (!el) return out;
if (typeof el.type === 'string') out.push(el as unknown as HostElement);
for (const child of el.children ?? []) walk(child, out);
return out;
}

function hosts(): HostElement[] {
return walk(screen.container, []);
return screen.container.queryAll((el) => typeof el.type === 'string') as HostElement[];
}

/** Every host element rendered under the given host name. */
Expand Down
Loading