diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..b7dd6df --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,16 @@ +name: Command dispatch tests + +on: + pull_request: + push: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v4 + with: + node-version: '22.19.0' + - run: node --experimental-vm-modules --test tests/*.test.cjs diff --git a/README.md b/README.md index 4196441..6fc4efb 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,25 @@ ## Development +### Native command dispatch + +On Android, imperative chart commands use `codegenNativeCommands` with the +current native host ref. Keep this path ref-based: converting the ref with +`findNodeHandle` and calling `UIManager.dispatchViewManagerCommand` sends Fabric +through `findShadowNodeByTag_DEPRECATED`. React Native 0.85.3's default lookup can +race native tree teardown (PRO-4970). Ref-based dispatch avoids that lookup for +chart commands without changing global React Native feature flags. + +iOS retains its existing numeric command mapping. Both paths check the current +ref at call time and ignore commands after detachment. The native command names, +argument order and payloads are unchanged. + +Run `yarn test` (Node 22+) for command routing, payload, detachment and independent +chart checks. These tests mock the native bridge; they do **not** prove the +intermittent Android SIGSEGV is resolved. Validate a mobile build containing this +package with repeated interval changes (including 3D → weekly), market and engine +switches, reconnects, background/foreground and idle runs, collecting native logs. + To start the project for: - Android: diff --git a/index.js b/index.js index 42a5976..8140d86 100644 --- a/index.js +++ b/index.js @@ -1,237 +1,62 @@ import React, { forwardRef, useImperativeHandle, useRef } from 'react'; import { requireNativeComponent, UIManager, findNodeHandle, Platform } from 'react-native'; +import codegenNativeCommands from 'react-native/Libraries/Utilities/codegenNativeCommands'; const NativeRNKLineView = requireNativeComponent('RNKLineView'); +const Commands = codegenNativeCommands({ + supportedCommands: [ + 'updateLastCandlestick', + 'addCandlesticksAtTheEnd', + 'addCandlesticksAtTheStart', + 'addOrderLine', + 'removeOrderLine', + 'updateOrderLine', + 'getOrderLines', + 'addBuySellMark', + 'removeBuySellMark', + 'updateBuySellMark', + 'getBuySellMarks', + ], +}); const RNKLineView = forwardRef((props, ref) => { const nativeRef = useRef(null); - useImperativeHandle(ref, () => ({ - updateLastCandlestick: (candlestick) => { - const nodeHandle = findNodeHandle(nativeRef.current); - if (nodeHandle) { - if (Platform.OS === 'ios') { - UIManager.dispatchViewManagerCommand( - nodeHandle, - UIManager.getViewManagerConfig('RNKLineView').Commands.updateLastCandlestick, - [candlestick] - ); - } else { - UIManager.dispatchViewManagerCommand( - nodeHandle, - 'updateLastCandlestick', - [candlestick] - ); - } - } else { - console.warn('No nodeHandle found for RNKLineView'); - } - }, - addCandlesticksAtTheEnd: (candlesticks) => { - const nodeHandle = findNodeHandle(nativeRef.current); - if (nodeHandle) { - if (Platform.OS === 'ios') { - UIManager.dispatchViewManagerCommand( - nodeHandle, - UIManager.getViewManagerConfig('RNKLineView').Commands.addCandlesticksAtTheEnd, - [candlesticks] - ); - } else { - UIManager.dispatchViewManagerCommand( - nodeHandle, - 'addCandlesticksAtTheEnd', - [candlesticks] - ); - } - } else { - console.warn('No nodeHandle found for RNKLineView'); - } - }, - addCandlesticksAtTheStart: (candlesticks) => { - const nodeHandle = findNodeHandle(nativeRef.current); - if (nodeHandle) { - if (Platform.OS === 'ios') { - UIManager.dispatchViewManagerCommand( - nodeHandle, - UIManager.getViewManagerConfig('RNKLineView').Commands.addCandlesticksAtTheStart, - [candlesticks] - ); - } else { - UIManager.dispatchViewManagerCommand( - nodeHandle, - 'addCandlesticksAtTheStart', - [candlesticks] - ); - } - } else { - console.warn('No nodeHandle found for RNKLineView'); - } - }, - addOrderLine: (orderLine) => { - const nodeHandle = findNodeHandle(nativeRef.current); - if (nodeHandle) { - if (Platform.OS === 'ios') { - UIManager.dispatchViewManagerCommand( - nodeHandle, - UIManager.getViewManagerConfig('RNKLineView').Commands.addOrderLine, - [orderLine] - ); - } else { - UIManager.dispatchViewManagerCommand( - nodeHandle, - 'addOrderLine', - [orderLine] - ); - } - } else { - console.warn('No nodeHandle found for RNKLineView'); - } - }, - removeOrderLine: (orderLineId) => { - const nodeHandle = findNodeHandle(nativeRef.current); - if (nodeHandle) { - if (Platform.OS === 'ios') { - UIManager.dispatchViewManagerCommand( - nodeHandle, - UIManager.getViewManagerConfig('RNKLineView').Commands.removeOrderLine, - [orderLineId] - ); - } else { - UIManager.dispatchViewManagerCommand( - nodeHandle, - 'removeOrderLine', - [orderLineId] - ); - } - } else { - console.warn('No nodeHandle found for RNKLineView'); - } - }, - updateOrderLine: (orderLine) => { - const nodeHandle = findNodeHandle(nativeRef.current); - if (nodeHandle) { - if (Platform.OS === 'ios') { - UIManager.dispatchViewManagerCommand( - nodeHandle, - UIManager.getViewManagerConfig('RNKLineView').Commands.updateOrderLine, - [orderLine] - ); - } else { - UIManager.dispatchViewManagerCommand( - nodeHandle, - 'updateOrderLine', - [orderLine] - ); - } - } else { - console.warn('No nodeHandle found for RNKLineView'); - } - }, - getOrderLines: () => { - const nodeHandle = findNodeHandle(nativeRef.current); - if (nodeHandle) { - if (Platform.OS === 'ios') { - return UIManager.dispatchViewManagerCommand( - nodeHandle, - UIManager.getViewManagerConfig('RNKLineView').Commands.getOrderLines, - [] - ); - } else { - return UIManager.dispatchViewManagerCommand( - nodeHandle, - 'getOrderLines', - [] - ); - } - } else { - console.warn('No nodeHandle found for RNKLineView'); - return []; - } - }, - addBuySellMark: (buySellMark) => { - const nodeHandle = findNodeHandle(nativeRef.current); - if (nodeHandle) { - if (Platform.OS === 'ios') { - UIManager.dispatchViewManagerCommand( - nodeHandle, - UIManager.getViewManagerConfig('RNKLineView').Commands.addBuySellMark, - [buySellMark] - ); - } else { - UIManager.dispatchViewManagerCommand( - nodeHandle, - 'addBuySellMark', - [buySellMark] - ); - } - } else { - console.warn('No nodeHandle found for RNKLineView'); - } - }, - removeBuySellMark: (buySellMarkId) => { - const nodeHandle = findNodeHandle(nativeRef.current); - if (nodeHandle) { - if (Platform.OS === 'ios') { - UIManager.dispatchViewManagerCommand( - nodeHandle, - UIManager.getViewManagerConfig('RNKLineView').Commands.removeBuySellMark, - [buySellMarkId] - ); - } else { - UIManager.dispatchViewManagerCommand( - nodeHandle, - 'removeBuySellMark', - [buySellMarkId] - ); - } - } else { - console.warn('No nodeHandle found for RNKLineView'); - } - }, - updateBuySellMark: (buySellMark) => { - const nodeHandle = findNodeHandle(nativeRef.current); - if (nodeHandle) { - if (Platform.OS === 'ios') { - UIManager.dispatchViewManagerCommand( - nodeHandle, - UIManager.getViewManagerConfig('RNKLineView').Commands.updateBuySellMark, - [buySellMark] - ); - } else { - UIManager.dispatchViewManagerCommand( - nodeHandle, - 'updateBuySellMark', - [buySellMark] - ); - } - } else { - console.warn('No nodeHandle found for RNKLineView'); - } - }, - getBuySellMarks: () => { - const nodeHandle = findNodeHandle(nativeRef.current); - if (nodeHandle) { - if (Platform.OS === 'ios') { - return UIManager.dispatchViewManagerCommand( - nodeHandle, - UIManager.getViewManagerConfig('RNKLineView').Commands.getBuySellMarks, - [] - ); - } else { - return UIManager.dispatchViewManagerCommand( - nodeHandle, - 'getBuySellMarks', - [] - ); - } - } else { - console.warn('No nodeHandle found for RNKLineView'); - return []; - } + const dispatch = (name, args, detachedResult) => { + const view = nativeRef.current; + if (!view) return detachedResult; + + if (Platform.OS === 'android') { + // Dispatch through the host ref. Numeric tags force Fabric through + // findShadowNodeByTag_DEPRECATED, whose RN 0.85 lookup can race teardown. + return Commands[name](view, ...args); } + + // Preserve iOS's exported numeric command mapping. + const nodeHandle = findNodeHandle(view); + if (!nodeHandle) return detachedResult; + return UIManager.dispatchViewManagerCommand( + nodeHandle, + UIManager.getViewManagerConfig('RNKLineView').Commands[name], + args + ); + }; + + useImperativeHandle(ref, () => ({ + updateLastCandlestick: (candlestick) => dispatch('updateLastCandlestick', [candlestick]), + addCandlesticksAtTheEnd: (candlesticks) => dispatch('addCandlesticksAtTheEnd', [candlesticks]), + addCandlesticksAtTheStart: (candlesticks) => dispatch('addCandlesticksAtTheStart', [candlesticks]), + addOrderLine: (orderLine) => dispatch('addOrderLine', [orderLine]), + removeOrderLine: (orderLineId) => dispatch('removeOrderLine', [orderLineId]), + updateOrderLine: (orderLine) => dispatch('updateOrderLine', [orderLine]), + getOrderLines: () => dispatch('getOrderLines', [], []), + addBuySellMark: (buySellMark) => dispatch('addBuySellMark', [buySellMark]), + removeBuySellMark: (buySellMarkId) => dispatch('removeBuySellMark', [buySellMarkId]), + updateBuySellMark: (buySellMark) => dispatch('updateBuySellMark', [buySellMark]), + getBuySellMarks: () => dispatch('getBuySellMarks', [], []), })); - return ; + return React.createElement(NativeRNKLineView, { ref: nativeRef, ...props }); }); export default RNKLineView; diff --git a/package.json b/package.json index cad2c82..ad473aa 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "registry": "https://npm.pkg.github.com/@elliottech" }, "scripts": { - "test": "echo \"Error: no test specified\" && exit 1", + "test": "node --experimental-vm-modules --test tests/*.test.cjs", "prepublishOnly": "echo \"Ready to publish\"" }, "keywords": [ diff --git a/tests/commands.test.cjs b/tests/commands.test.cjs new file mode 100644 index 0000000..f654bc9 --- /dev/null +++ b/tests/commands.test.cjs @@ -0,0 +1,108 @@ +const assert = require('node:assert/strict'); +const { readFile } = require('node:fs/promises'); +const path = require('node:path'); +const test = require('node:test'); +const vm = require('node:vm'); + +const names = [ + 'updateLastCandlestick', 'addCandlesticksAtTheEnd', 'addCandlesticksAtTheStart', + 'addOrderLine', 'removeOrderLine', 'updateOrderLine', 'getOrderLines', + 'addBuySellMark', 'removeBuySellMark', 'updateBuySellMark', 'getBuySellMarks', +]; + +async function mount(platform) { + const context = vm.createContext({}); + const ref = { current: null }; + const calls = []; + const host = { tag: 42 }; + const ids = Object.fromEntries(names.map((name, i) => [name, i + 1])); + const react = { + forwardRef: component => component, + useRef: value => ({ current: value }), + useImperativeHandle: (target, create) => { target.current = create(); }, + createElement: (type, props) => ({ type, props }), + }; + const modules = { + react: { default: react, ...react }, + 'react-native': { + requireNativeComponent: name => name, + Platform: { OS: platform }, + findNodeHandle: view => { calls.push(['lookup', view]); return view?.tag ?? null; }, + UIManager: { + getViewManagerConfig: () => ({ Commands: ids }), + dispatchViewManagerCommand: (...args) => { calls.push(['legacy', ...args]); }, + }, + }, + 'react-native/Libraries/Utilities/codegenNativeCommands': { + default: ({ supportedCommands }) => Object.fromEntries(supportedCommands.map(name => [ + name, (view, ...args) => { calls.push(['host', view, name, args]); }, + ])), + }, + }; + const source = await readFile(path.join(__dirname, '../index.js'), 'utf8'); + const module = new vm.SourceTextModule(source, { context }); + await module.link(name => { + const values = modules[name]; + return new vm.SyntheticModule(Object.keys(values), function () { + for (const [key, value] of Object.entries(values)) this.setExport(key, value); + }, { context }); + }); + await module.evaluate(); + const element = module.namespace.default({ testID: 'chart' }, ref); + element.props.ref.current = host; + return { api: ref.current, nativeRef: element.props.ref, host, calls, ids, element }; +} + +for (const platform of ['android', 'ios']) { + for (const name of names) { + test(`${platform}: ${name} preserves payload and uses the correct dispatch path`, async () => { + const { api, host, calls, ids } = await mount(platform); + const payload = { time: 123, close: 42 }; + const args = name.startsWith('get') ? [] : [payload]; + api[name](...args); + if (platform === 'android') { + assert.equal(calls.length, 1); + assert.equal(calls[0][0], 'host'); + assert.equal(calls[0][1], host); + assert.equal(calls[0][2], name); + } else { + assert.equal(calls.length, 2); + assert.equal(calls[0][0], 'lookup'); + assert.equal(calls[1][0], 'legacy'); + assert.equal(calls[1][1], host.tag); + assert.equal(calls[1][2], ids[name]); + } + const sent = calls.at(-1)[3]; + assert.equal(sent.length, args.length); + if (args.length) assert.equal(sent[0], payload); + }); + } + + test(`${platform}: retained API cannot dispatch after native ref detaches`, async () => { + const { api, nativeRef, calls } = await mount(platform); + nativeRef.current = null; + for (const name of names) { + const result = api[name]({ time: 123 }); + if (name.startsWith('get')) assert.equal(result.length, 0); + } + assert.equal(calls.length, 0); + }); + + test(`${platform}: retained API reads the current view after replacement`, async () => { + const { api, nativeRef, calls } = await mount(platform); + const replacement = { tag: 84 }; + nativeRef.current = replacement; + api.updateLastCandlestick({ close: 12 }); + assert.equal(calls[0][1], replacement); + }); +} + +test('independent charts dispatch to their own native refs', async () => { + const first = await mount('android'); + const second = await mount('android'); + first.nativeRef.current = null; + first.api.addCandlesticksAtTheEnd([]); + second.api.addCandlesticksAtTheEnd([]); + assert.equal(first.calls.length, 0); + assert.equal(second.calls[0][1], second.host); +});