From 37743faf0c0243c5dcf20515171c79c68b814465 Mon Sep 17 00:00:00 2001 From: LiZhenhai-MBP14 <5935568+jackhai9@users.noreply.github.com> Date: Fri, 28 Aug 2026 09:07:59 +0800 Subject: [PATCH] fix: wait for stable ladder action buttons --- scripts/binance-orderbook-trade.user.js | 70 +++++++++++++++- src/binance-orderbook-trade/dom/trade-form.js | 72 ++++++++++++++++ src/binance-orderbook-trade/index.user.js | 20 ++++- .../source-regressions.test.js | 8 ++ .../trade-form.test.js | 83 +++++++++++++++++++ 5 files changed, 245 insertions(+), 8 deletions(-) diff --git a/scripts/binance-orderbook-trade.user.js b/scripts/binance-orderbook-trade.user.js index 9c18031..0e8237b 100644 --- a/scripts/binance-orderbook-trade.user.js +++ b/scripts/binance-orderbook-trade.user.js @@ -3,7 +3,7 @@ // @namespace binance.orderbook.trade // @icon data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E // @icon64 data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E -// @version 2.7.141 +// @version 2.7.142 // @author jackhai9 // @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板 // @match https://www.binance.com/*/futures/* @@ -1507,6 +1507,57 @@ frameHandle = view.requestAnimationFrame(check); }); } + function waitForTradeActionButtonFrameState(observationRoot, findButton, isVisibleElement, timeoutMs, requiredStableFrames = 2) { + const view = observationRoot?.ownerDocument?.defaultView || observationRoot?.defaultView; + if (!view || typeof view.requestAnimationFrame !== "function" || typeof view.cancelAnimationFrame !== "function") { + throw new Error("Trade action button frame scheduler is unavailable"); + } + if (typeof findButton !== "function" || typeof isVisibleElement !== "function") { + throw new Error("Trade action button resolver is unavailable"); + } + if (!Number.isInteger(requiredStableFrames) || requiredStableFrames < 1) { + throw new Error("requiredStableFrames must be a positive integer"); + } + return new Promise((resolve) => { + let settled = false; + let frameHandle = 0; + let timer = 0; + let stableFrames = 0; + let stableButton = null; + const finish = (value) => { + if (settled) return; + settled = true; + if (frameHandle) view.cancelAnimationFrame(frameHandle); + view.clearTimeout(timer); + resolve(value); + }; + const check = () => { + frameHandle = 0; + const button = findButton(); + const actionable = Boolean( + button && button.isConnected && isVisibleElement(button) && !button.disabled && button.getAttribute("aria-disabled") !== "true" + ); + if (actionable) { + if (button === stableButton) { + stableFrames += 1; + } else { + stableButton = button; + stableFrames = 1; + } + if (stableFrames >= requiredStableFrames) { + finish(button); + return; + } + } else { + stableButton = null; + stableFrames = 0; + } + frameHandle = view.requestAnimationFrame(check); + }; + timer = view.setTimeout(() => finish(null), timeoutMs); + frameHandle = view.requestAnimationFrame(check); + }); + } function isTradeModeTab(node, { panelId }) { if (!node?.matches?.('[role="tab"]')) return false; if (node.closest(`#${panelId}`)) return false; @@ -2145,6 +2196,7 @@ const LADDER_CONTROL_BUTTON_FONT_SIZE = 14; const PANEL_BOTTOM_TOOLTIP_GAP = 12; const TRADE_UI_STATE_TIMEOUT_MS = 1e3; + const TRADE_ACTION_BUTTON_READY_TIMEOUT_MS = 2e3; let lastTs = 0; let isEditingMultiplier = false; let multiplierEditContext = null; @@ -3890,9 +3942,19 @@ const submittedPrice = synchronizedInputs.submittedPrice; assertLadderExecutionContext(plan); assertLadderMakerPrice(plan, submittedPrice); - const button = plan.spec.buttonGetter(); - if (!button || button.disabled || button.getAttribute("aria-disabled") === "true") { - throw new Error(`未找到可点击的${plan.spec.label}按钮`); + const button = await waitForTradeActionButtonFrameState( + document, + plan.spec.buttonGetter, + isVisibleElement, + TRADE_ACTION_BUTTON_READY_TIMEOUT_MS + ); + throwIfAborted(abortSignal); + if (!button) { + const currentButton = plan.spec.buttonGetter(); + if (!currentButton || !currentButton.isConnected || !isVisibleElement(currentButton)) { + throw new Error(`${plan.spec.label}按钮尚未渲染完成,已停止`); + } + throw new Error(`${plan.spec.label}按钮当前不可点击,已停止`); } if (!CFG.SAFE_MODE) { const previousFeedback = takeOrderFeedbackSnapshot(); diff --git a/src/binance-orderbook-trade/dom/trade-form.js b/src/binance-orderbook-trade/dom/trade-form.js index fe03f5a..ab3152a 100644 --- a/src/binance-orderbook-trade/dom/trade-form.js +++ b/src/binance-orderbook-trade/dom/trade-form.js @@ -514,6 +514,78 @@ export function waitForTradeFormFrameState( }); } +/** + * Binance can mark the requested trade mode active before React replaces the + * native action buttons. Require one live button identity to remain actionable + * across consecutive paint frames so callers never click the outgoing node. + */ +export function waitForTradeActionButtonFrameState( + observationRoot, + findButton, + isVisibleElement, + timeoutMs, + requiredStableFrames = 2, +) { + const view = observationRoot?.ownerDocument?.defaultView || observationRoot?.defaultView; + if ( + !view + || typeof view.requestAnimationFrame !== 'function' + || typeof view.cancelAnimationFrame !== 'function' + ) { + throw new Error('Trade action button frame scheduler is unavailable'); + } + if (typeof findButton !== 'function' || typeof isVisibleElement !== 'function') { + throw new Error('Trade action button resolver is unavailable'); + } + if (!Number.isInteger(requiredStableFrames) || requiredStableFrames < 1) { + throw new Error('requiredStableFrames must be a positive integer'); + } + + return new Promise((resolve) => { + let settled = false; + let frameHandle = 0; + let timer = 0; + let stableFrames = 0; + let stableButton = null; + const finish = (value) => { + if (settled) return; + settled = true; + if (frameHandle) view.cancelAnimationFrame(frameHandle); + view.clearTimeout(timer); + resolve(value); + }; + const check = () => { + frameHandle = 0; + const button = findButton(); + const actionable = Boolean( + button + && button.isConnected + && isVisibleElement(button) + && !button.disabled + && button.getAttribute('aria-disabled') !== 'true' + ); + if (actionable) { + if (button === stableButton) { + stableFrames += 1; + } else { + stableButton = button; + stableFrames = 1; + } + if (stableFrames >= requiredStableFrames) { + finish(button); + return; + } + } else { + stableButton = null; + stableFrames = 0; + } + frameHandle = view.requestAnimationFrame(check); + }; + timer = view.setTimeout(() => finish(null), timeoutMs); + frameHandle = view.requestAnimationFrame(check); + }); +} + export function isTradeModeTab(node, { panelId }) { if (!node?.matches?.('[role="tab"]')) return false; if (node.closest(`#${panelId}`)) return false; diff --git a/src/binance-orderbook-trade/index.user.js b/src/binance-orderbook-trade/index.user.js index 24e9de7..35b39da 100644 --- a/src/binance-orderbook-trade/index.user.js +++ b/src/binance-orderbook-trade/index.user.js @@ -3,7 +3,7 @@ // @namespace binance.orderbook.trade // @icon data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E // @icon64 data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E -// @version 2.7.141 +// @version 2.7.142 // @author jackhai9 // @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板 // @match https://www.binance.com/*/futures/* @@ -129,6 +129,7 @@ import { parseLeverageButtonText, placeTradePanelSpacer, readTradeAvailableBalance, + waitForTradeActionButtonFrameState, waitForTradeFormFrameState, waitForTradeFormMutationState, } from './dom/trade-form.js'; @@ -325,6 +326,7 @@ import { const LADDER_CONTROL_BUTTON_FONT_SIZE = 14; const PANEL_BOTTOM_TOOLTIP_GAP = 12; const TRADE_UI_STATE_TIMEOUT_MS = 1000; + const TRADE_ACTION_BUTTON_READY_TIMEOUT_MS = 2000; let lastTs = 0; let isEditingMultiplier = false; @@ -2497,9 +2499,19 @@ import { assertLadderExecutionContext(plan); assertLadderMakerPrice(plan, submittedPrice); - const button = plan.spec.buttonGetter(); - if (!button || button.disabled || button.getAttribute('aria-disabled') === 'true') { - throw new Error(`未找到可点击的${plan.spec.label}按钮`); + const button = await waitForTradeActionButtonFrameState( + document, + plan.spec.buttonGetter, + isVisibleElement, + TRADE_ACTION_BUTTON_READY_TIMEOUT_MS, + ); + throwIfAborted(abortSignal); + if (!button) { + const currentButton = plan.spec.buttonGetter(); + if (!currentButton || !currentButton.isConnected || !isVisibleElement(currentButton)) { + throw new Error(`${plan.spec.label}按钮尚未渲染完成,已停止`); + } + throw new Error(`${plan.spec.label}按钮当前不可点击,已停止`); } if (!CFG.SAFE_MODE) { diff --git a/test/unit/binance-orderbook-trade/source-regressions.test.js b/test/unit/binance-orderbook-trade/source-regressions.test.js index 9d44f6e..50b73f4 100644 --- a/test/unit/binance-orderbook-trade/source-regressions.test.js +++ b/test/unit/binance-orderbook-trade/source-regressions.test.js @@ -144,6 +144,14 @@ test('trade mode and Post Only switches wait for observed state instead of fixed assert.doesNotMatch(source, /findConditionalSubtypeCombobox|findPostOnlyOption|clickElementLikeUser/); }); +test('ladder execution waits for the current semantic action button before every submit', () => { + const executeBody = readFunctionBody('executeLadderPlan'); + + assert.match(executeBody, /await waitForTradeActionButtonFrameState/); + assert.match(executeBody, /plan\.spec\.buttonGetter/); + assert.doesNotMatch(executeBody, /const button = plan\.spec\.buttonGetter\(\);\s*if/); +}); + test('trade input synchronization confirms live controlled values instead of sleeping', () => { const setInputBody = readFunctionBody('setInputValueReact'); const syncBody = readFunctionBody('syncTradeInputs'); diff --git a/test/unit/binance-orderbook-trade/trade-form.test.js b/test/unit/binance-orderbook-trade/trade-form.test.js index 13fb127..7a6b2fb 100644 --- a/test/unit/binance-orderbook-trade/trade-form.test.js +++ b/test/unit/binance-orderbook-trade/trade-form.test.js @@ -15,6 +15,7 @@ import { parseTradeModeLabel, placeTradePanelSpacer, readTradeAvailableBalance, + waitForTradeActionButtonFrameState, waitForTradeFormFrameState, waitForTradeFormMutationState, } from '../../../src/binance-orderbook-trade/dom/trade-form.js'; @@ -227,6 +228,88 @@ test('trade form frame wait rejects a value that never remains synchronized', as ); }); +test('trade action button wait survives the selected tab preceding its action buttons', async () => { + const dom = loadFixtureDom('
开仓
'); + const { document } = dom.window; + const root = document.querySelector('#trade-form'); + let frame = 0; + dom.window.requestAnimationFrame = (callback) => dom.window.setTimeout(() => { + frame += 1; + if (frame === 2) { + const button = document.createElement('button'); + button.textContent = '开空'; + root.append(button); + } + callback(); + }, 0); + dom.window.cancelAnimationFrame = (handle) => dom.window.clearTimeout(handle); + + const button = await waitForTradeActionButtonFrameState( + document, + () => Array.from(root.querySelectorAll('button')).find((candidate) => candidate.textContent === '开空') || null, + () => true, + 100, + 2, + ); + + assert.equal(button?.textContent, '开空'); + assert.equal(button?.isConnected, true); + assert.equal(frame, 3); +}); + +test('trade action button wait restarts stability after React replaces the button node', async () => { + const dom = loadFixtureDom('
'); + const { document } = dom.window; + const root = document.querySelector('#trade-form'); + const originalButton = root.querySelector('button'); + let replacementButton = null; + let resolvedButton = originalButton; + let frame = 0; + dom.window.requestAnimationFrame = (callback) => dom.window.setTimeout(() => { + frame += 1; + if (frame === 2) { + replacementButton = document.createElement('button'); + replacementButton.textContent = '平空'; + originalButton.replaceWith(replacementButton); + } + if (frame === 3) resolvedButton = replacementButton; + callback(); + }, 0); + dom.window.cancelAnimationFrame = (handle) => dom.window.clearTimeout(handle); + + const button = await waitForTradeActionButtonFrameState( + document, + () => resolvedButton, + () => true, + 100, + 2, + ); + + assert.equal(button, replacementButton); + assert.equal(originalButton.isConnected, false); + assert.equal(button?.isConnected, true); + assert.equal(frame, 4); +}); + +test('trade action button wait rejects a button that remains disabled', async () => { + const dom = loadFixtureDom('
'); + const { document } = dom.window; + const root = document.querySelector('#trade-form'); + dom.window.requestAnimationFrame = (callback) => dom.window.setTimeout(callback, 0); + dom.window.cancelAnimationFrame = (handle) => dom.window.clearTimeout(handle); + + assert.equal( + await waitForTradeActionButtonFrameState( + document, + () => root.querySelector('button'), + () => true, + 10, + 2, + ), + null, + ); +}); + test('unexpected trade-mode structure is rejected instead of inserting at a guessed location', () => { const dom = loadFixtureDom(`