بنقرة واحدة
react-native
// Guidelines for writing React Native code in Liftosaur. Use when migrating web components to RN primitives, fixing RN performance issues, or building new RN screens.
// Guidelines for writing React Native code in Liftosaur. Use when migrating web components to RN primitives, fixing RN performance issues, or building new RN screens.
Fix a production exception from Rollbar in an interactive Claude Code session. Fetches the occurrence data, analyzes the error, and either implements a fix or adds the error to the ignore list. Use when given a Rollbar occurrence ID.
Write, debug, and integrate Lezer grammars for the Liftosaur project. Use when creating or modifying .grammar files, evaluators, syntax highlighting, or CodeMirror integration.
Write idiomatic Liftoscript code for weightlifting programs. Use when creating or editing program files in programs/builtin/.
Writing style guide for prose content - program descriptions, technique pages, blog posts, and any user-facing text. Use when writing or editing descriptive content.
Capture important architectural decisions, bug patterns, feature designs, or other critical findings into the project knowledge base. Use proactively when significant technical decisions are made, non-obvious bugs are resolved, new subsystems are designed, or important product features are discussed.
Add a new server-rendered page to the Liftosaur website. Use when creating new public-facing pages with routing, SSR, and client hydration.
| name | react-native |
| description | Guidelines for writing React Native code in Liftosaur. Use when migrating web components to RN primitives, fixing RN performance issues, or building new RN screens. |
| disable-model-invocation | true |
Apply these rules when writing or migrating React Native code in Liftosaur.
React Native is NOT a browser. Browsers render 6,000 DOM nodes trivially; RN creates a real native view for each component over the JS bridge. Every <View className="..."> also runs NativeWind's CssInterop at runtime in JS (~1ms per View). This means:
initialNumToRender: Set to the number of items visible on screen (typically 2-4). NOT 10.maxToRenderPerBatch: Can be higher (6) for smooth scroll-ahead rendering.windowSize: 3-5 is typical. Larger = more off-screen rendering.getItemLayout: Always provide if items have predictable height/width. Avoids measurement passes.initialScrollIndex: Use with getItemLayout to jump to a position without rendering everything before it.keyExtractor: Always provide, use stable IDs.removeClippedSubviews={true}: Consider for long lists to unmount off-screen views.FlatList does NOT support changing the onViewableItemsChanged callback after mount. Use useRef:
const onViewableItemsChanged = useRef(
({ viewableItems }: { viewableItems: Array<{ item: T }> }) => {
// Access changing values through refs, not closure
const currentValue = someValueRef.current;
}
).current;
Always wrap renderItem in useCallback. If the rendered component is complex, make it a memo() component.
| HTML | RN Primitive | Notes |
|---|---|---|
div | View | |
span | Text | RN Text does NOT nest inside View implicitly |
p | Text | |
button | Pressable + Text | Never use TouchableOpacity (deprecated) |
img | Image | Must have explicit width/height |
section | View | |
h1-h6 | Text with className | |
ul/li | View | |
a | Pressable + Text or Link | |
svg/path | Svg/Path from ./primitives/svg |
Image requires explicit width and height. Unlike HTML img, it won't size from the source./images/foo.png) don't work on native — there's no webpack dev server proxy.HostConfig_resolveUrl(path) from src/utils/hostConfig.ts to prepend the host on native.HostConfig_resolveUrl is a no-op on web (returns path as-is).Use Platform.select for cross-platform shadows:
style={Platform.select({
ios: { shadowColor: "#000", shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.1, shadowRadius: 3 },
android: { elevation: 2 },
default: {}, // web uses className shadow
})}
CSS animation and @keyframes don't exist in RN. Use Animated.View + Animated.timing:
const spinValue = useRef(new Animated.Value(0)).current;
useEffect(() => {
const animation = Animated.loop(
Animated.timing(spinValue, { toValue: 1, duration: 1000, easing: Easing.linear, useNativeDriver: true })
);
animation.start();
return () => animation.stop();
}, []);
const rotate = spinValue.interpolate({ inputRange: [0, 1], outputRange: ["0deg", "360deg"] });
return <Animated.View style={{ transform: [{ rotate }] }}>...</Animated.View>;
className="rotate-180" doesn't work on native for transforms. Use inline style:
style={{ transform: [{ rotate: "180deg" }] }}
Text.defaultProps is broken in React 19 / New Architecture. NativeWind's @layer base doesn't work on native. The only reliable way to set a default font is a wrapper component:
// src/components/primitives/text.tsx
import { Text as RNText, TextProps, StyleSheet } from "react-native";
const defaultStyle = StyleSheet.create({ text: { fontFamily: "Poppins" } });
export function Text({ style, className, ...props }: TextProps & { className?: string }) {
return <RNText className={className} style={[defaultStyle.text, style]} {...props} />;
}
All migrated components must import Text from ./primitives/text, not from react-native.
react-native-web's Text sets inline font-family: System. Override in src/index.css:
* { font-family: inherit !important; }
Font files live in assets/fonts/. Linked via react-native.config.js assets field. After adding new fonts:
npx react-native-asset
cd ios && pod install
Then do a full rebuild + reinstall on simulator (incremental builds don't pick up new resources).
Always keep both data-cy and testID on interactive/testable elements:
<View data-cy="history-entry-exercise" testID="history-entry-exercise">
data-cy is for Playwright web tests (webapp)testID is for native testingNever wrap a scrollable component (FlatList, ScrollView) inside a Pressable. The Pressable captures touch gestures before the scrollable can handle swipes. Instead, put the onPress handler on individual items inside the list.
When using pagingEnabled, the snap points are based on the FlatList's visible width. If you measure width via onLayout, measure the container the FlatList sits in directly — not a parent with padding/margin. Otherwise items and snap points will be misaligned.
The web app uses a full <Markdown> component with many extensions. For the mobile app, use <SimpleMarkdown> from src/components/simpleMarkdown.tsx — lightweight renderer using markdown-it + RN primitives.
@react-navigation/native-stack with formSheet presentation for modalssrc/navigation/screens/.tsx file that works on both web and native over .native.tsx + .tsx pairs.native.tsx — Metro resolves .native.tsx first, creating an infinite import loopsrc/utils/hostConfig.ts provides HostConfig_imageHost() and HostConfig_resolveUrl(path). Toggle the baseHost constant for local/stage/prod, similar to Settings.swift on the iOS side:
const baseHost = "https://local.liftosaur.com:8080";
// const baseHost = "https://stage.liftosaur.com";
// const baseHost = "https://www.liftosaur.com";
presentation: "formSheet" uses iOS UISheetPresentationController, which has a gesture recognizer that conflicts with FlatList/ScrollView scrolling. Symptoms: first ~30px of scroll is glitchy after the modal opens. The glitch scales with the number of Views in FlatList items (7 day-cells = fine, 14+ = glitch). This is a react-native-screens issue.
Solution: Use presentation: "transparentModal" with a custom SheetScreenContainer.native.tsx that builds the sheet UI manually:
Animated.View for slide-up/down animationPanResponder on the grabber for drag-to-dismissuseWindowDimensionsThe web version (SheetScreenContainer.tsx) uses createPortal — incompatible with native. The .native.tsx version replaces it entirely.
CSS grid grid-cols-7 doesn't exist in RN. Use flexDirection: 'row', flexWrap: 'wrap' with width: "14.285%" per cell:
<View className="flex-row flex-wrap">
{days.map((day) => (
<View key={day} style={{ width: "14.285%" }} className="items-center justify-center p-2">
...
</View>
))}
</View>
scrollToIndex fails when the target item hasn't been rendered yet. Two approaches:
Pre-compute estimated heights so FlatList knows every item's position upfront. Then scrollToIndex works for any index instantly. Follow with a second scrollToIndex after ~200ms to correct to the actual measured position:
const itemLayouts = useMemo(() => {
const layouts = [];
let offset = 0;
for (const item of data) {
const length = estimateItemHeight(item);
layouts.push({ length, offset });
offset += length;
}
return layouts;
}, [data]);
// On FlatList:
getItemLayout={(_, index) => ({ ...itemLayouts[index], index })}
// When scrolling:
flatListRef.current.scrollToIndex({ index, animated: false });
setTimeout(() => {
flatListRef.current?.scrollToIndex({ index, animated: false });
}, 200);
Without getItemLayout, scrollToOffset is clamped to rendered content size. The onScrollToIndexFailed + retry pattern creates loops. Avoid this for long lists.
For lists that should show newest-first but display in ascending visual order (oldest at top, newest at bottom), use descending data + inverted={true}:
<FlatList data={descendingData} inverted />
This starts the scroll at the bottom (newest item) without needing initialScrollIndex or scrollToEnd.
.tsx vs .tsx + .native.tsxDefault to a single cross-platform .tsx with Platform.OS === "web" checks for tiny differences. Only create a .native.tsx variant when the implementation diverges meaningfully (e.g. file picker uses an HTML <input> on web vs @react-native-documents/picker on native).
Better pattern when behavior diverges: extract the platform-specific I/O into a tiny utility (.ts + .native.ts), and keep the component itself cross-platform. Example — three importer components share one cross-platform file each, with file picking and confirm dialogs delegated to src/utils/fileImport.ts / .native.ts:
// fileImport.ts (web)
export async function FileImport_pickFile(): Promise<string | undefined> {
return new Promise((resolve) => {
const input = document.createElement("input");
input.type = "file";
input.onchange = () => { /* ...FileReader... */ };
input.click();
});
}
export async function FileImport_confirm(message: string): Promise<boolean> {
return Promise.resolve(window.confirm(message));
}
// fileImport.native.ts
import { Alert } from "react-native";
import { pick, types } from "@react-native-documents/picker";
import RNFS from "react-native-fs";
export async function FileImport_pickFile(): Promise<string | undefined> {
const [result] = await pick({ type: [types.json] });
return await RNFS.readFile(decodeURIComponent(result.uri), "utf8");
}
export async function FileImport_confirm(message: string): Promise<boolean> {
return new Promise((resolve) => {
Alert.alert("Confirm", message, [
{ text: "Cancel", style: "cancel", onPress: () => resolve(false) },
{ text: "OK", onPress: () => resolve(true) },
]);
});
}
The component then has a single shared .tsx that calls these helpers — no .native.tsx variant needed.
It's safe to import { Modal } from "./modal" (a web-only file using react-dom) in a cross-platform component, as long as the actual usage is gated by Platform.OS === "web". Metro resolves the import but the gated function is never called on native, so it doesn't crash. This avoids needing stub .native.tsx files for every web-only dependency.
const isWeb = Platform.OS === "web";
return (
<>
<View>...navbar...</View>
{props.helpContent && isWeb && (
<Modal isHidden={!show} onClose={...}>{...}</Modal> // never executes on native
)}
</>
);
Same pattern works for other web-only utilities like Link (<a>), createPortal, document.body.classList, etc.
If you need to attach DOM-only event handlers (e.g. onMouseDown for drag-and-drop) on a cross-platform component, gate with Platform.OS === "web" and use React.createElement("div"|"span", ...) directly to bypass the RN type system:
const dragHandle =
props.handleTouchStart && Platform.OS === "web"
? React.createElement(
"div",
{ className: "p-2 cursor-move", style: { marginLeft: "-16px" } },
React.createElement(
"span",
{ onMouseDown: props.handleTouchStart, onTouchStart: props.handleTouchStart },
React.createElement(IconHandle)
)
)
: null;
The runtime check ensures createElement("div", ...) is never invoked on native.
setNativePropsA controlled TextInput round-trips every keystroke through the bridge → laggy. Use uncontrolled mode with a ref + setNativeProps to push external value changes:
export const Input = memo(forwardRef(function Input(props: IProps): JSX.Element {
const inputRef = useRef<TextInput>(null);
const currentValueRef = useRef(String(props.value ?? ""));
// Sync external value changes WITHOUT making it controlled
useEffect(() => {
if (props.value === undefined) return;
const newStr = String(props.value);
if (currentValueRef.current !== newStr) {
currentValueRef.current = newStr;
inputRef.current?.setNativeProps({ text: newStr });
}
}, [props.value]);
return (
<TextInput
ref={inputRef}
defaultValue={currentValueRef.current}
onChangeText={(text) => { currentValueRef.current = text; }}
onBlur={() => props.changeHandler?.({ success: true, data: currentValueRef.current })}
keyboardType={props.type === "number" ? "numeric" : "default"}
selectTextOnFocus
/>
);
}));
The change handler fires on blur, not on every keystroke. currentValueRef is the source of truth in-memory.
MenuItemEditable needs text, number, boolean, select, desktop-select types. On native:
boolean → Switch from react-nativeselect / desktop-select → Pressable that opens ActionSheetIOS.showActionSheetWithOptionstext / number → uncontrolled TextInput (see pattern above)Layout: name container with flex-1, value container as a regular column — flex-1 on the name pushes the value to the right naturally. Don't put flex-1 items-end on a column-direction wrapper (alignItems on the wrong axis).
Linking is exported from react-native and works on both web and native (react-native-web shims it). Use Linking.openURL(url) for external links instead of <a href>:
import { Linking } from "react-native";
function openExternal(url: string): void {
Linking.openURL(url).catch(() => undefined);
}
<Pressable onPress={() => openExternal("https://discord.com/...")}><Text>Discord</Text></Pressable>
SendMessage_isIos() and SendMessage_isAndroid() check window.webkit.messageHandlers / window.JSAndroidBridge — both return false in pure RN. Any code guarded by these checks auto-hides on native with no extra work:
If you want similar behavior to render natively, you'd add a Platform.OS === "ios" || Platform.OS === "android" check instead.
These don't exist on RN — replace before sharing code:
window.setTimeout → setTimeout (no window prefix)window.confirm → Alert.alert (wrap in a Promise for sync-style use)window.pageYOffset / window.addEventListener("scroll", ...) → ScrollView.onScrollwindow.document.* / document.body.classList → only inside Platform.OS === "web" branchesdocument.createElement → only inside web-only utilities@react-navigation/native-stack does support custom JS-rendered headers. Set both headerShown: true and header::
const navHeaderScreenOptions = {
headerShown: true,
animation: "slide_from_right" as const,
freezeOnBlur: true,
header: NavHeader,
};
<MeStack.Navigator screenOptions={navHeaderScreenOptions}>
<MeStack.Screen name="settings" component={NavScreenSettings} />
...
</MeStack.Navigator>
native-stack renders the JS header at y=0 (under the status bar). Wrap the navbar in a View with paddingTop: insets.top:
const insets = useSafeAreaInsets();
return (
<View className="bg-background-default" style={{ paddingTop: insets.top }}>
<NavbarView ... />
</View>
);
SafeAreaProvider must be set up in App.native.tsx (it is). On web, useSafeAreaInsets returns zeros — no visual change.
useNavOptions works cross-platformuseNavOptions calls navigation.setOptions(...) in a useEffect. native-stack does re-call the header: function when options change, so screens just call useNavOptions({ navTitle: "Me" }) and the navbar updates. Same hook works on both web and native.
@react-navigation/stack (web) and native-stackDon't import StackHeaderProps from a specific stack package. Use a loose type with just the fields you need:
interface IHeaderProps {
options: object;
back?: { title: string | undefined } | undefined;
}
export function NavHeader(props: IHeaderProps): JSX.Element | null { ... }
Both StackHeaderProps and NativeStackHeaderProps are structurally compatible with this shape.
native-stack animation options: "none", "default", "fade", "fade_from_bottom", "flip", "simple_push", "slide_from_bottom", "slide_from_right", "slide_from_left". Use "slide_from_right" for the standard iOS push (slide-in + back-swipe gesture).
iOS shadows extend in all directions from a View's bounds. If you put the shadow on the navbar View itself, the top edge of the shadow bleeds into the safe-area area. Solution: put the shadow on the wrapper View that extends from y=0 (very top of screen) to the bottom of the navbar — the top half of the shadow goes off-screen and only the bottom shows.
const shadowStyle = options.navIsScrolled
? Platform.select({
ios: { shadowColor: "#000", shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.08, shadowRadius: 4 },
android: { elevation: 4 },
default: { boxShadow: "0 4px 4px -2px rgba(0,0,0,0.08)" },
})
: undefined;
return (
<View className="bg-background-default" style={[{ paddingTop: insets.top }, shadowStyle]}>
<NavbarView ... />
</View>
);
Note the negative spread in the web boxShadow (-2px) — it shrinks the shadow inward so the visible blur is fully below the box rather than wrapping around. CSS doesn't have a "bottom-only shadow" but 0 4px 4px -2px rgba(...) is the standard trick.
Have the screen's scrollable container (NavScreenContent) report scroll state to navigation options, but only when crossing the threshold (scrollY === 0 ↔ scrollY > 0). Use a ref to track previous state:
const navigation = useNavigation();
const isScrolledRef = useRef(false);
const onScroll = useCallback((e: NativeSyntheticEvent<NativeScrollEvent>) => {
const isScrolled = e.nativeEvent.contentOffset.y > 0;
if (isScrolled !== isScrolledRef.current) {
isScrolledRef.current = isScrolled;
navigation.setOptions({ navIsScrolled: isScrolled });
}
}, [navigation]);
return <ScrollView onScroll={onScroll} scrollEventThrottle={16}>...</ScrollView>;
NavHeader reads options.navIsScrolled and applies the shadow style. setOptions is only called twice per scroll session (down and back up), not every frame.
When you npm install a package with native code (slider, document picker, anything with iOS/Android folders):
npm install @react-native-community/slider
cd ios && pod install
# Then REBUILD the iOS app:
npm run ios # or open Xcode + Cmd+R
A Metro reload alone is not enough. Symptom: Unimplemented component: <RNCSlider> — the JS bridge can't find a native module that hasn't been linked into the binary.
If npm run ios fails on Ruby gems (ffi errors), open ios/Liftosaur.xcworkspace in Xcode and Cmd+R there. Pods are already installed by the earlier pod install.
data-cy for Playwright TestsPlaywright is configured with testIdAttribute: "data-cy" in playwright.config.ts. When you migrate a web component to use RN primitives, keep data-cy alongside testID:
<Pressable testID={testId} data-cy={testId} onPress={...}>
react-native-web passes unknown props through to the underlying DOM element on web; on native it's ignored. TypeScript accepts it because the project's type config doesn't strictly enforce RN's prop types. Don't drop data-cy when converting — every getByTestId(...) call in tests/*.spec.ts depends on it.
Cross-platform slider lives at src/components/primitives/slider.tsx + slider.native.tsx:
React.createElement("input", { type: "range", ... })@react-native-community/slider with onSlidingComplete (not onValueChange — that fires on every drag pixel)Use step for discrete snapping. The component takes a simple { value, min, max, step?, onChange } interface so consumers don't need to think about HTML events vs RN events.
Must use ObjC++ with codegen, not Swift or legacy bridge. See memory feedback_turbo_modules.md for details.
runOnJSGesture.Pan().onStart / .onUpdate / .onEnd run on the UI thread as worklets. Calling a regular JS function (including setState) directly from them throws "Tried to synchronously call a non-worklet function on the UI thread." Wrap every JS call with runOnJS:
import { runOnJS } from "react-native-reanimated";
const updateCursor = useCallback((x: number) => setCursorIdx(findIdx(x)), [findIdx]);
const pan = Gesture.Pan()
.onStart((e) => { runOnJS(updateCursor)(e.x); })
.onUpdate((e) => { runOnJS(updateCursor)(e.x); });
Reading/writing shared values (sharedValue.value) inside the worklet is fine — only plain JS calls need runOnJS.
A child Gesture.Pan() inside a ScrollView competes with the native scroll recognizer. Two key patterns:
.activateAfterLongPress(150) — Pan only activates after a 150ms hold. Quick vertical swipes scroll the ScrollView normally; long-press engages the child Pan..minPointers(2).maxPointers(2) — two-finger gestures (pan/pinch for zoom) activate immediately without conflicting with scroll, since scroll uses single-finger drag.scrollEnabled={false} ≠ layout-shift protectionDisabling ScrollView scroll prevents scroll gestures but does NOT prevent contentOffset clamping when contentSize shrinks (e.g. dynamic content below the viewport getting smaller). The visible viewport can still jump up. For full stability, either keep contentSize constant (absolute-positioned overlays) or reserve large enough buffer below.
When content height varies during interaction (chart legend, popover, etc.), inline layout flow causes visible reflow. Render the variable content as an absolutely-positioned overlay on top of the stable content — zero layout impact, no reflow, no scroll jumps.
For exclusive activation (only one overlay visible at a time across siblings), use a small context:
// activeGraphContext.ts
export const ActiveGraphContext = createContext<{ activeId: string | null; setActive: (id: string | null) => void }>({
activeId: null,
setActive: () => undefined,
});
Each interactive child reads activeId === myId and clears its own state via a useEffect when it stops being active. Starting interaction on another graph calls setActive(otherId), which switches exclusivity automatically.
Rapid setState in a parent (e.g. cursor tracking at user-drag rate) re-renders the whole subtree. NativeWind's CssInterop runs ~1ms per className'd View per render; at high update rates it can't keep up and unrelated <Text>s may render with stale/no styles — they look "removed" mid-interaction even though they never unmount.
Fix: isolate the frequently-updating state into the smallest possible subcomponent. Siblings (title, Select, etc.) then stay stable across updates.
function GraphExercise(props) {
const [selectedType, setSelectedType] = useState(...);
// Title + Select here — never re-render from cursor
return (
<View>
<TitleAndSelect ... />
<ChartAndLegend selectedType={selectedType} ... /> // cursorIdx lives inside
</View>
);
}
flex-* classes on <Text> break layoutReact Native Text does NOT lay its children out with flex — children flow as inline text runs. Putting flex-row flex-wrap items-center on <Text> is ignored by layout but still processed by NativeWind; this can produce layout instability, especially when the container's height changes dynamically (e.g. content crossing minHeight), and has been observed to cause text rendering glitches.
Use <View flex-row flex-wrap> for flex layout; keep <Text> for pure text.
<Pressable> inside <Text> is fragileDon't nest a Pressable inside a Text. Put both inside a <View flex-row flex-wrap> so the Pressable is a sibling of the Text, not a child.
box-content is a no-opTailwind's box-content sets box-sizing: content-box. RN doesn't honor CSS box-sizing — remove the class.
When the parent needs to trigger an action on a child (clear cursor, scroll to item), expose a handle:
export interface ILineChartHandle { clearCursor: () => void }
export const LineChart = forwardRef<ILineChartHandle, IProps>(function LineChart(props, ref) {
const [cursorIdx, setCursorIdx] = useState<number | null>(null);
useImperativeHandle(ref, () => ({ clearCursor: () => setCursorIdx(null) }), []);
...
});
// Parent:
const ref = useRef<ILineChartHandle>(null);
<LineChart ref={ref} ... />
// Later: ref.current?.clearCursor();
For simple dynamic-size animations (e.g. GroupHeader toggling its content), LayoutAnimation works cross-platform (including react-native-web) without reanimated:
import { LayoutAnimation, Platform, UIManager } from "react-native";
function onToggle() {
if (Platform.OS === "android" && UIManager.setLayoutAnimationEnabledExperimental) {
UIManager.setLayoutAnimationEnabledExperimental(true);
}
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
setExpanded((v) => !v);
}
react-native-svg has web support; use via ./primitives/svg (svg.tsx uses raw DOM createElement("svg"|"path"|...), svg.native.tsx re-exports from react-native-svg). Chart props (stroke, strokeWidth, fill, strokeDasharray, transform="rotate(90, x, y)", etc.) work identically on both.
Attribute naming: use camelCase on SVG elements (strokeWidth, not stroke-width). <circle> transform="matrix(-1 0 0 1 x y)" mirror transforms can be simplified to direct cx/cy since mirroring a circle is visually a no-op.
For interactive charts, combine SVG rendering with GestureDetector. Keep cursor and viewport as React state (not reanimated shared values) — adequate performance for typical chart sizes (≤500 points) and simpler code. Expose imperative API (e.g. clearCursor) via forwardRef for parent-driven resets.
Both libraries work on RN Web, so drag-and-drop reorder can be a single .tsx. Pattern used in DraggableList2:
Gesture.Pan().activateAfterLongPress(150) — doesn't fight scrolltranslateY shared value for the dragged itemshiftY shared value for other items making space (animated via withTiming during drag, instant reset on drop)useLayoutEffect (not useEffect) to reset shared values synchronously after React commits the reorder — useEffect runs after paint and produces a visible flashtranslateY in the worklet onEnd — the UI-thread reset fires before React re-renders with the new item order, causing a "snap back to initial position" flash. Let useLayoutEffect handle it after the reorder.When introducing a new cross-platform version (e.g. DraggableList2), keep the legacy DOM-based one alongside so existing consumers aren't forced to migrate in one go.
register-rn-web.js aliases react-native → react-native-web for ts-node SSR. But react-native-gesture-handler and react-native-reanimated use TurboModuleRegistry.getEnforcing at import time, which crashes in Node:
TypeError: Cannot read properties of undefined (reading 'getEnforcing')
at .../react-native-gesture-handler/src/specs/NativeRNGestureHandlerModule.ts
Any cross-platform component using these libraries will be pulled into lambda SSR (e.g. user.tsx / record.tsx → UserHtml → GraphExercise → LineChart → RNGH).
Fix: add Node-safe stub modules and alias them in register-rn-web.js:
src/utils/rnStubs/gestureHandler.js — chainable no-op Gesture.Pan()/Pinch()/... builders (each method returns the builder), pass-through GestureDetector and GestureHandlerRootView that render their childrensrc/utils/rnStubs/reanimated.js — useSharedValue(v) → { value: v }, useAnimatedStyle() → {}, runOnJS(fn) → fn, withTiming(v) → v, pass-through Animated.View, etc.In register-rn-web.js:
const gestureHandlerStub = path.join(__dirname, "src/utils/rnStubs/gestureHandler.js");
const reanimatedStub = path.join(__dirname, "src/utils/rnStubs/reanimated.js");
Module._resolveFilename = function (request, parent, isMain, options) {
if (request === "react-native" || request.startsWith("react-native/")) {
return origResolve.call(this, "react-native-web", parent, isMain, options);
}
if (request === "react-native-gesture-handler" || request.startsWith("react-native-gesture-handler/")) {
return gestureHandlerStub;
}
if (request === "react-native-reanimated" || request.startsWith("react-native-reanimated/")) {
return reanimatedStub;
}
return origResolve.call(this, request, parent, isMain, options);
};
The lambda now renders SVG charts statically (no interactivity) without touching native-only modules.
src/components/primitives/select.tsx + .native.tsx pair:
React.createElement("select", { value, onChange }, options.map(o => createElement("option", ...)))Pressable that shows label + opens ActionSheetIOS.showActionSheetWithOptionsSimple { value, options: Array<{ value, label }>, onChange, className } API — consumers don't deal with HTML events vs. native pickers.
NativeWind's slash-opacity syntax bg-color-name/50 does not work reliably on RN for custom theme colors (in some setups it renders as fully transparent). Use Colors_hexToRgba(hex, alpha) from src/utils/colors.ts with the resolved theme color:
import { Colors_hexToRgba } from "../utils/colors";
import { Tailwind_semantic } from "../utils/tailwindConfig";
style={{ backgroundColor: Colors_hexToRgba(Tailwind_semantic().background.subtlecardpurple, 0.5) }}
This produces rgba(r, g, b, 0.5) that RN accepts directly.