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(`