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
14 changes: 14 additions & 0 deletions .claude/skills/raise-pr/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,20 @@ git push -u origin fix/issue-<number>-<short-slug>

---

## Step 2b — Self-Review Before Creating PR

**Always review your own diff before creating the PR.** Run `git diff HEAD~1` (or `git diff main...HEAD`) and check for:

- **Orphaned or misplaced JSDoc comments** — moving code can separate a JSDoc from its function
- **Unnecessary `as any` casts** — use specific types wherever possible (e.g., `as React.ComponentType<any>` instead of `as any`)
- **Leftover debug code** — `console.log`, `fetch("http://localhost:...")`, temporary comments
- **Unrelated changes** — files or hunks that aren't part of the fix
- **Formatting issues** — the diff should be clean and minimal

Fix any issues found, then amend the commit before proceeding to Step 3.

---

## Step 3 — Create the PR

### PR title
Expand Down
16 changes: 14 additions & 2 deletions src/FlashListProps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,18 +93,26 @@ export interface FlashListProps<TItem>
*
* Note: Changing layout of the cell can conflict with the native layout operations. You may need to set `disableAutoLayout` to `true` to prevent this.
*/
CellRendererComponent?: React.ComponentType<any> | undefined;
CellRendererComponent?:
| React.ComponentType<any>
| React.ExoticComponent<any>
| undefined;

/**
* Rendered in between each item, but not at the top or bottom. By default, `leadingItem` and `trailingItem` (if available) props are provided.
*/
ItemSeparatorComponent?: React.ComponentType<any> | null | undefined;
ItemSeparatorComponent?:
| React.ComponentType<any>
| React.ExoticComponent<any>
| null
| undefined;

/**
* Rendered when the list is empty. Can be a React Component (e.g. `SomeComponent`), or a React element (e.g. `<SomeComponent />`).
*/
ListEmptyComponent?:
| React.ComponentType<any>
| React.ExoticComponent<any>
| React.ReactElement
| null
| undefined;
Expand All @@ -114,6 +122,7 @@ export interface FlashListProps<TItem>
*/
ListFooterComponent?:
| React.ComponentType<any>
| React.ExoticComponent<any>
| React.ReactElement
| null
| undefined;
Expand All @@ -128,6 +137,7 @@ export interface FlashListProps<TItem>
*/
ListHeaderComponent?:
| React.ComponentType<any>
| React.ExoticComponent<any>
| React.ReactElement
| null
| undefined;
Expand All @@ -142,6 +152,7 @@ export interface FlashListProps<TItem>
*/
renderScrollComponent?:
| React.ComponentType<ScrollViewProps>
| React.ExoticComponent<ScrollViewProps>
| React.FC<ScrollViewProps>;

/**
Expand Down Expand Up @@ -397,6 +408,7 @@ export interface FlashListProps<TItem>
*/
backdropComponent?:
| React.ComponentType<any>
| React.ExoticComponent<any>
| React.ReactElement
| null
| undefined;
Expand Down
83 changes: 83 additions & 0 deletions src/__tests__/componentUtils.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import React from "react";

import {
getValidComponent,
isComponentClass,
} from "../recyclerview/utils/componentUtils";

const SimpleComponent = () => React.createElement("View");
const MemoizedComponent = React.memo(SimpleComponent);
const ForwardRefComponent = React.forwardRef(function ForwardRefComp(
_props,
_ref
) {
return React.createElement("View");
});

describe("getValidComponent", () => {
it("returns null for null", () => {
expect(getValidComponent(null)).toBeNull();
});

it("returns null for undefined", () => {
expect(getValidComponent(undefined)).toBeNull();
});

it("renders a function component", () => {
const result = getValidComponent(SimpleComponent);
expect(result).not.toBeNull();
expect(React.isValidElement(result)).toBe(true);
});

it("renders a React.memo component", () => {
const result = getValidComponent(MemoizedComponent);
expect(result).not.toBeNull();
expect(React.isValidElement(result)).toBe(true);
});

it("renders a React.forwardRef component", () => {
const result = getValidComponent(ForwardRefComponent);
expect(result).not.toBeNull();
expect(React.isValidElement(result)).toBe(true);
});

it("passes through a pre-rendered element", () => {
const element = React.createElement(SimpleComponent);
const result = getValidComponent(element);
expect(result).toBe(element);
});
});

describe("isComponentClass", () => {
it("returns true for a class component", () => {
// eslint-disable-next-line react/prefer-stateless-function
class MyClass extends React.Component {
render() {
return null;
}
}
expect(isComponentClass(MyClass)).toBe(true);
});

it("returns false for a function component", () => {
expect(isComponentClass(SimpleComponent)).toBe(false);
});

it("returns false for React.memo", () => {
expect(isComponentClass(MemoizedComponent)).toBe(false);
});

it("returns false for React.forwardRef", () => {
expect(isComponentClass(ForwardRefComponent)).toBe(false);
});

it("returns false for null and undefined", () => {
expect(isComponentClass(null)).toBe(false);
expect(isComponentClass(undefined)).toBe(false);
});

it("returns false for a plain function", () => {
const renderFn = () => React.createElement("View");
expect(isComponentClass(renderFn)).toBe(false);
});
});
18 changes: 12 additions & 6 deletions src/recyclerview/hooks/useSecondaryProps.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Animated, RefreshControl } from "react-native";
import React, { useMemo } from "react";

import { RecyclerViewProps } from "../RecyclerViewProps";
import { getValidComponent } from "../utils/componentUtils";
import { getValidComponent, isComponentClass } from "../utils/componentUtils";
import { CompatView } from "../components/CompatView";
import { CompatAnimatedScroller } from "../components/CompatScroller";

Expand Down Expand Up @@ -121,16 +121,22 @@ export function useSecondaryProps<T>(props: RecyclerViewProps<T>) {
* If no custom component is provided, uses the default CompatAnimatedScroller.
*/
const CompatScrollView = useMemo(() => {
let scrollComponent = CompatAnimatedScroller;
if (typeof renderScrollComponent === "function") {
let scrollComponent: React.ComponentType<any> = CompatAnimatedScroller;
if (
typeof renderScrollComponent === "function" &&
!isComponentClass(renderScrollComponent)
) {
// Create a forwarded ref wrapper for the custom scroll component
const ForwardedScrollComponent = React.forwardRef((_props, ref) =>
(renderScrollComponent as any)({ ..._props, ref } as any)
(renderScrollComponent as (...args: unknown[]) => React.ReactNode)({
..._props,
ref,
})
);
ForwardedScrollComponent.displayName = "CustomScrollView";
scrollComponent = ForwardedScrollComponent as any;
scrollComponent = ForwardedScrollComponent as React.ComponentType<any>;
} else if (renderScrollComponent) {
scrollComponent = renderScrollComponent;
scrollComponent = renderScrollComponent as React.ComponentType<any>;
}
// Wrap the scroll component with Animated.createAnimatedComponent
return Animated.createAnimatedComponent(scrollComponent);
Expand Down
32 changes: 27 additions & 5 deletions src/recyclerview/utils/componentUtils.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,31 @@
import React from "react";

type RenderableComponent =
| React.ComponentType
| React.ExoticComponent
| React.ReactElement
| null
| undefined;

/**
* Returns true if the value is a React class component.
* Class components set `prototype.isReactComponent` per React convention,
* which distinguishes them from plain functions and render props.
*/
export const isComponentClass = (value: unknown): boolean =>
typeof value === "function" &&
Boolean(
(value as { prototype?: { isReactComponent?: unknown } }).prototype
?.isReactComponent
);

/**
* Helper function to handle both React components and React elements.
* This utility ensures proper rendering of components whether they are passed as
* component types or pre-rendered elements.
* component types or pre-rendered elements. Supports function components, class
* components, React.memo, React.forwardRef, and pre-rendered elements.
*
* @param component - Can be a React component type, React element, null, or undefined
* @param component - Can be a React component type, exotic component, React element, null, or undefined
* @returns A valid React element if the input is valid, null otherwise
*
* @example
Expand All @@ -17,12 +37,14 @@ import React from "react";
* getValidComponent(<MyComponent />)
*/
export const getValidComponent = (
component: React.ComponentType | React.ReactElement | null | undefined
component: RenderableComponent
): React.ReactElement | null => {
if (React.isValidElement(component)) {
return component;
} else if (typeof component === "function") {
return React.createElement(component);
} else if (component != null) {
// Cast needed: React.createElement's type overloads don't include ExoticComponent,
// but it handles memo/forwardRef/lazy correctly at runtime.
return React.createElement(component as React.ComponentType);
}
return null;
};