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
+127
View File
@@ -340,3 +340,130 @@ describe('React Native cross-platform pairing — end to end', () => {
expect(pair.c).toBeGreaterThanOrEqual(2); // java<->objc both directions
});
});
// =============================================================================
// Swift modules via RCT_EXTERN_MODULE, and receiver evidence
// =============================================================================
import { parseObjcRNExterns, collectNativeModuleAliases } from '../src/resolution/frameworks/react-native';
function swiftMethod(name: string, owner: string, filePath: string, startLine: number): Node {
return {
id: `swift:${filePath}:${name}:${startLine}`,
kind: 'method',
name,
qualifiedName: `${owner}::${name}`,
filePath,
language: 'swift',
startLine,
endLine: startLine + 4,
startColumn: 0,
endColumn: 0,
updatedAt: Date.now(),
} as Node;
}
const SHIM = `
#import <React/RCTBridgeModule.h>
#import <React/RCTViewManager.h>
@interface RCT_EXTERN_MODULE(CaptureView, RCTViewManager)
RCT_EXTERN_METHOD(syncSettings:(NSDictionary *)settings)
RCT_EXTERN_METHOD(finalizeCaptureSession)
RCT_EXTERN_REMAP_METHOD(pause, pauseInferenceNow)
@end
`;
describe('React Native bridge resolver — RCT_EXTERN (Swift) modules', () => {
const finalize = swiftMethod('finalizeCaptureSession', 'CaptureView', 'ios/CaptureView+ReactBridge.swift', 26);
const sync = swiftMethod('syncSettings', 'CaptureView', 'ios/CaptureView.swift', 40);
const pause = swiftMethod('pauseInferenceNow', 'CaptureView', 'ios/CaptureView.swift', 60);
// Same method name on another Swift type — never the bridge target.
const decoy = swiftMethod('syncSettings', 'CaptureSettings', 'ios/CaptureSettings.swift', 12);
const files = {
'package.json': '{"name":"app","dependencies":{"react-native":"0.76"}}',
'ios/CaptureView.m': SHIM,
'src/components/capture/capture-view.tsx':
"import { NativeModules, NativeEventEmitter } from 'react-native'\n" +
'export const { CaptureEvents } = NativeModules\n' +
'export const captureView = NativeModules.CaptureView\n',
};
const ctx = makeContext([finalize, sync, pause, decoy], files);
it('parses the shim: module, class, first keyword, remap', () => {
expect(parseObjcRNExterns(SHIM).map((e) => [e.moduleName, e.className, e.jsName, e.nativeSelectorFirstKw])).toEqual([
['CaptureView', 'CaptureView', 'syncSettings', 'syncSettings'],
['CaptureView', 'CaptureView', 'finalizeCaptureSession', 'finalizeCaptureSession'],
['CaptureView', 'CaptureView', 'pause', 'pauseInferenceNow'],
]);
const remapped = parseObjcRNExterns('@interface RCT_EXTERN_REMAP_MODULE(Camera, CameraModule, NSObject)\nRCT_EXTERN_METHOD(snap)');
expect(remapped).toEqual([
{ moduleName: 'Camera', className: 'CameraModule', jsName: 'snap', nativeSelectorFirstKw: 'snap', line: 2 },
]);
});
it('collects the local names bound to NativeModules', () => {
const aliases = new Map<string, string>();
const ambiguous = new Set<string>();
collectNativeModuleAliases(
'const captureView = NativeModules.CaptureView\n' +
'export const { CaptureEvents, Geo: geolocation } = NativeModules\n' +
'let typed: Spec = NativeModules.Typed\n',
aliases,
ambiguous
);
// Direct bindings first (one pass), then the destructured ones.
expect([...aliases]).toEqual([
['captureView', 'CaptureView'],
['typed', 'Typed'],
['CaptureEvents', 'CaptureEvents'],
['geolocation', 'Geo'],
]);
// The same name bound to two modules is dropped, not guessed.
collectNativeModuleAliases('const captureView = NativeModules.Other', aliases, ambiguous);
expect(aliases.has('captureView')).toBe(false);
expect(ambiguous.has('captureView')).toBe(true);
});
it('detects a project from the RCT_EXTERN_MODULE marker alone', () => {
expect(reactNativeBridgeResolver.detect(makeContext([], { 'ios/CaptureView.m': SHIM }))).toBe(true);
});
it('resolves an aliased receiver to the Swift method of the named class at 0.95', () => {
const r = reactNativeBridgeResolver.resolve(
ref('captureView.finalizeCaptureSession', 'tsx', 'src/hooks/use-review-handlers.ts'),
ctx
);
expect(r?.targetNodeId).toBe(finalize.id);
expect(r?.confidence).toBe(0.95);
expect(r?.metadata).toEqual({ bridge: 'react-native', module: 'CaptureView' });
});
it('resolves NativeModules.Module.method the same way, class-scoped past a same-named decoy', () => {
const r = reactNativeBridgeResolver.resolve(ref('NativeModules.CaptureView.syncSettings', 'tsx', 'src/a.tsx'), ctx);
expect(r?.targetNodeId).toBe(sync.id);
expect(r?.confidence).toBe(0.95);
});
it('follows RCT_EXTERN_REMAP_METHOD to the Swift implementation under the JS name', () => {
const r = reactNativeBridgeResolver.resolve(ref('captureView.pause', 'tsx', 'src/a.tsx'), ctx);
expect(r?.targetNodeId).toBe(pause.id);
});
it('keeps a bare method name at the by-name confidence, and refuses a named module that lacks the method', () => {
const bare = reactNativeBridgeResolver.resolve(ref('syncSettings', 'tsx', 'src/a.tsx'), ctx);
expect(bare?.targetNodeId).toBe(sync.id);
expect(bare?.confidence).toBe(0.6);
expect(reactNativeBridgeResolver.resolve(ref('captureView.nothingHere', 'tsx', 'src/a.tsx'), ctx)).toBeNull();
// A receiver that is NOT a module alias falls back to by-name evidence.
const other = reactNativeBridgeResolver.resolve(ref('somethingElse.syncSettings', 'tsx', 'src/a.tsx'), ctx);
expect(other?.confidence).toBe(0.6);
});
it('never redirects a native caller', () => {
expect(reactNativeBridgeResolver.resolve(ref('captureView.finalizeCaptureSession', 'swift', 'ios/x.swift'), ctx)).toBeNull();
});
});