Skip to content

Instantly share code, notes, and snippets.

@code-yeongyu
Last active December 26, 2025 12:28
Show Gist options
  • Select an option

  • Save code-yeongyu/17a2ea81b5960c99452c5950a0aa164a to your computer and use it in GitHub Desktop.

Select an option

Save code-yeongyu/17a2ea81b5960c99452c5950a0aa164a to your computer and use it in GitHub Desktop.
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
"""
React Native anti-pattern detector hook.
Env vars:
- RN_HOOK_SHOW_INFO=1: Show info-level warnings
- RN_HOOK_DISABLED=1: Disable hook
"""
from __future__ import annotations
import json
import os
import re
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any
@dataclass
class PatternMatch:
key: str
description: str
suggestion: str
severity: str
@dataclass
class CompiledPattern:
key: str
regex: re.Pattern[str]
description: str
suggestion: str
severity: str
KEYBOARD_AVOIDING_VIEW_WRONG_IMPORT = re.compile(
r"import\s+\{[^}]*KeyboardAvoidingView[^}]*\}\s+from\s+['\"]react-native['\"]",
re.MULTILINE | re.IGNORECASE,
)
KEYBOARD_OFFSET_ZERO = re.compile(
r"keyboardVerticalOffset\s*=\s*\{?\s*0\s*\}?",
re.MULTILINE | re.IGNORECASE,
)
PLATFORM_CONDITIONAL_KEYBOARD = re.compile(
r"Platform\.OS\s*===?\s*['\"]ios['\"]\s*\?\s*['\"]padding['\"]",
re.MULTILINE | re.IGNORECASE,
)
FLATLIST_MISSING_KEYEXTRACTOR = re.compile(
r"<FlatList(?![^>]*keyExtractor)[^>]*>",
re.MULTILINE | re.IGNORECASE,
)
FLATLIST_MISSING_GETITEMLAYOUT = re.compile(
r"<FlatList(?![^>]*getItemLayout)[^>]*>",
re.MULTILINE | re.IGNORECASE,
)
INLINE_STYLE_OBJECT = re.compile(
r"style=\{\{[^}]+\}\}",
re.MULTILINE | re.IGNORECASE,
)
ANONYMOUS_FUNCTION_PROP = re.compile(
r"on\w+=\{\s*\([^)]*\)\s*=>\s*",
re.MULTILINE | re.IGNORECASE,
)
IMAGE_WITHOUT_DIMENSIONS = re.compile(
r"<Image[^>]*source=\{[^}]+\}(?![^>]*(width|height|style=))[^>]*/>",
re.MULTILINE | re.IGNORECASE,
)
COMPILED_PATTERNS: list[CompiledPattern] = [
CompiledPattern(
key="keyboard-avoiding-view-wrong-import",
regex=KEYBOARD_AVOIDING_VIEW_WRONG_IMPORT,
description="KeyboardAvoidingView imported from 'react-native'",
suggestion="Use 'react-native-keyboard-controller' instead",
severity="error",
),
CompiledPattern(
key="keyboard-offset-zero",
regex=KEYBOARD_OFFSET_ZERO,
description="keyboardVerticalOffset set to 0",
suggestion="Should be header height + insets.top (e.g., 44 + insets.top)",
severity="warning",
),
CompiledPattern(
key="platform-conditional-keyboard",
regex=PLATFORM_CONDITIONAL_KEYBOARD,
description="Platform-conditional keyboard behavior",
suggestion="Unnecessary with react-native-keyboard-controller. Use behavior='padding' directly.",
severity="warning",
),
CompiledPattern(
key="flatlist-missing-keyextractor",
regex=FLATLIST_MISSING_KEYEXTRACTOR,
description="FlatList without keyExtractor",
suggestion="Always provide keyExtractor: keyExtractor={(item) => item.id}",
severity="warning",
),
CompiledPattern(
key="flatlist-missing-getitemlayout",
regex=FLATLIST_MISSING_GETITEMLAYOUT,
description="FlatList without getItemLayout (performance)",
suggestion="For fixed-height items, add getItemLayout for instant scroll-to-index",
severity="info",
),
CompiledPattern(
key="inline-style-object",
regex=INLINE_STYLE_OBJECT,
description="Inline style object detected",
suggestion="Move to StyleSheet.create() or useMemo() to prevent re-renders",
severity="info",
),
CompiledPattern(
key="anonymous-function-prop",
regex=ANONYMOUS_FUNCTION_PROP,
description="Anonymous arrow function in prop",
suggestion="Consider useCallback() to prevent child re-renders",
severity="info",
),
CompiledPattern(
key="image-without-dimensions",
regex=IMAGE_WITHOUT_DIMENSIONS,
description="Image without explicit dimensions",
suggestion="Always specify width/height for network images",
severity="info",
),
]
LAYOUT_CHECKS: dict[str, tuple[str, str, str]] = {
"missing-safe-area-provider": (
"_layout.tsx: SafeAreaProvider 없음",
"<SafeAreaProvider>로 앱 감싸기 (from react-native-safe-area-context)",
"error",
),
"missing-keyboard-provider": (
"_layout.tsx: KeyboardProvider 없음 (react-native-keyboard-controller)",
"<KeyboardProvider statusBarTranslucent navigationBarTranslucent>로 앱 감싸기",
"error",
),
"keyboard-provider-incomplete": (
"_layout.tsx: KeyboardProvider에 props 누락",
"<KeyboardProvider statusBarTranslucent navigationBarTranslucent>",
"warning",
),
}
def check_file(file_path: str, content: str | None = None) -> list[PatternMatch]:
path = Path(file_path)
if path.suffix not in (".tsx", ".jsx", ".ts", ".js"):
return []
if "node_modules" in str(path):
return []
if content is None:
try:
content = path.read_text()
except Exception:
return []
matches: list[PatternMatch] = []
seen_keys: set[str] = set()
filename = path.name
for pattern in COMPILED_PATTERNS:
if pattern.key in seen_keys:
continue
if pattern.regex.search(content):
seen_keys.add(pattern.key)
matches.append(
PatternMatch(
key=pattern.key,
description=pattern.description,
suggestion=pattern.suggestion,
severity=pattern.severity,
)
)
if "_layout" in filename:
if "SafeAreaProvider" not in content:
key = "missing-safe-area-provider"
if key not in seen_keys:
seen_keys.add(key)
desc, sugg, sev = LAYOUT_CHECKS[key]
matches.append(PatternMatch(key=key, description=desc, suggestion=sugg, severity=sev))
has_keyboard_provider = "KeyboardProvider" in content
has_keyboard_controller_import = "react-native-keyboard-controller" in content
if not has_keyboard_provider or not has_keyboard_controller_import:
key = "missing-keyboard-provider"
if key not in seen_keys:
seen_keys.add(key)
desc, sugg, sev = LAYOUT_CHECKS[key]
matches.append(PatternMatch(key=key, description=desc, suggestion=sugg, severity=sev))
elif has_keyboard_provider and "statusBarTranslucent" not in content:
key = "keyboard-provider-incomplete"
if key not in seen_keys:
seen_keys.add(key)
desc, sugg, sev = LAYOUT_CHECKS[key]
matches.append(PatternMatch(key=key, description=desc, suggestion=sugg, severity=sev))
return matches
def format_matches(matches: list[PatternMatch], file_path: str, show_info: bool = False) -> str:
if not matches:
return ""
if show_info:
filtered = matches
else:
filtered = [m for m in matches if m.severity in ("error", "warning")]
if not filtered:
return ""
severity_order = {"error": 0, "warning": 1, "info": 2}
filtered.sort(key=lambda m: severity_order.get(m.severity, 3))
lines = [
f"\n🔍 React Native Pattern Check: {Path(file_path).name}",
"=" * 50,
]
severity_emoji = {"error": "🚨", "warning": "⚠️", "info": "ℹ️"}
for match in filtered:
emoji = severity_emoji.get(match.severity, "•")
lines.append(f"\n{emoji} [{match.severity.upper()}] {match.description}")
if match.suggestion:
lines.append(f" → {match.suggestion}")
lines.append("\n" + "=" * 50)
return "\n".join(lines)
def is_rn_project(file_path: Path) -> bool:
check_dir = file_path.parent
for _ in range(10):
for marker in ["metro.config.js", "app.json", "expo-env.d.ts"]:
if (check_dir / marker).exists():
return True
pkg_json = check_dir / "package.json"
if pkg_json.exists():
try:
if "react-native" in pkg_json.read_text():
return True
except Exception:
pass
if check_dir.parent == check_dir:
break
check_dir = check_dir.parent
return False
def main() -> None:
if os.environ.get("RN_HOOK_DISABLED", "").strip() == "1":
sys.exit(0)
show_info = os.environ.get("RN_HOOK_SHOW_INFO", "").strip() == "1"
try:
input_raw = sys.stdin.read()
if not input_raw.strip():
sys.exit(0)
data: dict[str, Any] = json.loads(input_raw)
except (json.JSONDecodeError, KeyError, TypeError):
sys.exit(0)
tool_name = data.get("tool_name", "")
if tool_name not in ("Write", "Edit", "MultiEdit", "Read"):
sys.exit(0)
tool_input = data.get("tool_input", {})
if not isinstance(tool_input, dict):
sys.exit(0)
file_path = tool_input.get("file_path") or tool_input.get("filePath", "")
if not file_path:
sys.exit(0)
path = Path(file_path)
if path.suffix not in (".tsx", ".jsx", ".ts", ".js"):
sys.exit(0)
if not is_rn_project(path):
sys.exit(0)
content = None
tool_response = data.get("tool_response", {})
if isinstance(tool_response, dict):
content = tool_response.get("content")
matches = check_file(file_path, content)
if matches:
has_significant = any(m.severity in ("error", "warning") for m in matches)
if has_significant or (show_info and matches):
output = format_matches(matches, file_path, show_info)
if output:
print(output, file=sys.stderr)
sys.exit(2)
sys.exit(0)
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment