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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user