feat(expo-router): add Expo Router support for Screens and navigations and introduce Steps API

Introduce Expo Router integration with a new Screens view and API to surface screens and transitions, plus a new Steps API and UI to depict typed steps from anchors or symbols. Extend codegraph’s extraction and resolution to handle namespace objects (export default NAME, two-statement forms, and default bindings) and React hook bindings for handlers, improving accuracy of flows across JS ↔ native boundaries. Add Swift/React Native bridge receiver evidence (RCT_EXTERN_MODULE, RCT_EXTERN_METHOD) and related resolution logic, with tests covering namespace-object resolution, useCallback-driven handlers, and inline RN event listeners. Update UI to include a Steps tab and associated components (StepsView, StepNode, ScreenEdge) and wire navigation to expose steps-based exploration via /api/steps and UI routes. Documentation and changelog reflect the new Expo Router integration and steps surface capabilities.
This commit is contained in:
Colby McHenry
2026-08-28 09:46:50 -05:00
parent f0eafe31f9
commit 873f133c96
36 changed files with 3711 additions and 73 deletions
+29 -1
View File
@@ -293,6 +293,29 @@ impl<'t> Walker<'t> {
// --- extractVariable (TS/JS branch) ------------------------------------------------
/// A top-level binding exported by a LATER statement rather than at its
/// declaration: `export default NAME`, `export { NAME }`, `export { NAME as
/// default }`. The declaration's own `is_exported` (an `export_statement`
/// ancestor) cannot see these. One anchored regex over the file source.
/// Mirrors TreeSitterExtractor.isExportedLater.
pub(super) fn is_exported_later(&self, name: &str) -> bool {
if name.is_empty()
|| !name.chars().next().map(|c| c.is_ascii_alphabetic() || c == '_' || c == '$').unwrap_or(false)
|| !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
{
return false;
}
let n = regex::escape(name);
let pattern = format!(
r"(?m)^[ \t]*export\s+(?:default\s+{n}\s*;?[ \t]*$|\{{[^}}]*\b{n}\b[^}}]*\}})",
n = n
);
match regex::Regex::new(&pattern) {
Ok(re) => re.is_match(self.src),
Err(_) => false,
}
}
pub(super) fn extract_variable(&mut self, node: Node<'t>) {
let is_const = self.is_const_decl(node);
let kind: &'static str = if is_const { "constant" } else { "variable" };
@@ -373,7 +396,12 @@ impl<'t> Walker<'t> {
let has_inline_fns = object_of_fns
.map(|o| self.object_has_inline_functions(o))
.unwrap_or(false);
let extract_object_methods = is_exported && object_of_fns.is_some() && has_inline_fns;
// "Exported" includes the two-statement form `const useStore =
// create(…)` … `export default useStore` (is_exported_later), the
// shape most React Native stores are written in. Mirrors
// TreeSitterExtractor.isExportedLater.
let extract_object_methods =
(is_exported || self.is_exported_later(&name)) && object_of_fns.is_some() && has_inline_fns;
let rtk_endpoints = match value {
Some(v) if v.kind() == "call_expression" => self.find_rtk_endpoints_object(v),
+6 -1
View File
@@ -39,6 +39,11 @@ pub fn dispatch(kind: &str) -> Option<Mode> {
"variable_declarator" => Some(Mode::VarInit),
"pair" => Some(Mode::Value),
"array" => Some(Mode::List),
// A JSX attribute value or child (`onPress={handleSubmit}`): the
// expression's one named child is the value. Mirrors TS_JS_SPEC.
"jsx_expression" => Some(Mode::List),
// An object literal's shorthand members (`return { handleApprove }`).
"object" => Some(Mode::List),
_ => None,
}
}
@@ -117,7 +122,7 @@ pub fn capture(container: Node, mode: Mode, src: &str) -> Vec<(Candidate, Mode)>
/// `this.<member>` member_expression special form (object EXACTLY `this`).
fn normalize<'t>(node: Node<'t>, src: &str) -> Vec<(String, Node<'t>)> {
match node.kind() {
"identifier" => vec![(src[node.byte_range()].to_string(), node)],
"identifier" | "shorthand_property_identifier" => vec![(src[node.byte_range()].to_string(), node)],
"member_expression" => {
let obj = node.child_by_field_name("object");
let prop = node.child_by_field_name("property");
+46 -1
View File
@@ -704,13 +704,20 @@ impl<'t> Walker<'t> {
self.extract_variable_type_annotation(node, owner);
}
// Nested NAMED functions become their own nodes.
// Nested NAMED functions become their own nodes — and so does the
// function a React handler hook binds a name to (`const onPress =
// useCallback(() => {…}, [])`). Mirrors TreeSitterExtractor's
// reactHookBoundName.
if is_function_type(kind) {
let name = self.extract_name(node);
if name != "<anonymous>" {
self.extract_function(node, None);
return;
}
if let Some(bound) = self.react_hook_bound_name(node) {
self.extract_function(node, Some(bound));
return;
}
}
if is_class_type(self.variant, kind) {
@@ -735,6 +742,44 @@ impl<'t> Walker<'t> {
// --- name / signature / modifier helpers ------------------------------------
/// The declarator name a React handler hook binds an anonymous function
/// to — `const NAME = useCallback(<node>, [...])` (also `React.useCallback`,
/// `useEffectEvent`, `useEvent`) — or None for any other shape. The node
/// must be the call's FIRST argument and the call's value must be bound
/// directly by a `variable_declarator`.
fn react_hook_bound_name(&self, node: Node<'t>) -> Option<String> {
if !matches!(node.kind(), "arrow_function" | "function_expression") {
return None;
}
let args = node.parent()?;
if args.kind() != "arguments" {
return None;
}
let first = args.named_child(0)?;
if first.start_byte() != node.start_byte() || first.end_byte() != node.end_byte() {
return None;
}
let call = args.parent()?;
if call.kind() != "call_expression" {
return None;
}
let callee = call.child_by_field_name("function")?;
let callee_text = self.text(callee);
let hook = callee_text.strip_prefix("React.").unwrap_or(callee_text);
if !matches!(hook, "useCallback" | "useEffectEvent" | "useEvent") {
return None;
}
let declarator = call.parent()?;
if declarator.kind() != "variable_declarator" {
return None;
}
let name_node = declarator.child_by_field_name("name")?;
if name_node.kind() != "identifier" {
return None;
}
Some(self.text(name_node).to_string())
}
/// extractName / extractNameRaw for the TS/JS configs.
fn extract_name(&self, node: Node) -> String {
// javascriptExtractor.resolveName: field_definition names its key the