Skip to content

Commit 666f7eb

Browse files
authored
fix(ecosystem): fifo miniapp sheets + tx object result (#426)
1 parent 20fcc33 commit 666f7eb

14 files changed

Lines changed: 305 additions & 50 deletions

File tree

AGENTS.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,43 @@ gh pr merge <pr#> --squash --delete-branch
109109
- **严禁主目录开发**: 必须使用 `task start` 创建的 Worktree。
110110
- **Schema-first**: 服务开发必须先定义 `types.ts`
111111

112+
---
113+
114+
## Agent 工作方式(约定)
115+
116+
> 目标:避免“围绕 DOM 修修补补”导致的路径爆炸;用可验证的状态机 + 纯函数绑定 DOM。
117+
118+
### 1) 状态机优先(State-first)
119+
120+
- 先把“控制层状态”定义清楚(例如:miniapp 是 `active/backgrounded`,sheet 是否 `pending/visible/resolved`)。
121+
- 先完成状态迁移/队列/FIFO 等控制逻辑,再把状态映射到 DOM(DOM 只是最后一步渲染)。
122+
- 使用函数式工具函数做绑定:
123+
- `derive*`:从状态派生视图状态(纯函数)
124+
- `apply*ToDom`:把派生后的视图状态写入 DOM(集中处理,避免到处散落 `style/classList`
125+
126+
### 2) KISS:样式控制收敛到单一入口
127+
128+
- 不在多个层级叠加 `opacity/visibility/display/pointer-events` 进行互相对冲。
129+
- 约定由“单一视图状态”驱动:
130+
- `interactive`(是否可交互)→ `pointer-events`
131+
- `visible`(是否可见)→ `opacity``visibility`
132+
- **禁止**通过移除 DOM 节点/父节点来实现后台化(iframe 被移除会被释放)。
133+
134+
### 3) Miniapp Sheet 统一 FIFO(按 appId)
135+
136+
- 所有需要 UI Sheet 的 handler 必须通过 `enqueueMiniappSheet(appId, task)` 串行化。
137+
- 目标:同一 app 的敏感动作“先入先出”,且不会被新弹窗打断。
138+
139+
### 4) Worktree 与 PR 管理
140+
141+
- 一律从 repo 根目录执行 `pnpm agent task start`,避免在 worktree 内再次创建 worktree(会出现嵌套 worktree)。
142+
- PR 合并后清理无用 worktree/分支,保持本地环境干净可控。
143+
144+
### 5) 验证要求
145+
146+
- 修改完成后至少跑一次:`pnpm agent review verify`
147+
- 对“协议/URL 拼接/缓存”这类易回归问题优先补单测,确保在 CI 中可复现。
148+
112149
<!-- OPENSPEC:START -->
113150
# OpenSpec Instructions
114151

packages/ecosystem-native/src/components/home-button.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ export class HomeButton extends LitElement {
5353
velocityThreshold = 0.3;
5454

5555
private swipeDetector = createUpSwipeDetector();
56+
private ignoreNextClick = false;
57+
private ignoreClickTimeoutId: ReturnType<typeof globalThis.setTimeout> | null = null;
5658

5759
override connectedCallback(): void {
5860
super.connectedCallback();
@@ -74,6 +76,11 @@ export class HomeButton extends LitElement {
7476
}
7577

7678
override disconnectedCallback(): void {
79+
if (this.ignoreClickTimeoutId !== null) {
80+
globalThis.clearTimeout(this.ignoreClickTimeoutId);
81+
this.ignoreClickTimeoutId = null;
82+
}
83+
7784
this.removeEventListener('touchstart', this.handleTouchStart, { capture: true });
7885
this.removeEventListener('touchend', this.handleTouchEnd, { capture: true });
7986
this.removeEventListener('touchcancel', this.handleTouchCancel, { capture: true });
@@ -114,6 +121,18 @@ export class HomeButton extends LitElement {
114121

115122
if (result.detected && result.direction === 'up') {
116123
e.preventDefault();
124+
125+
// Some browsers still fire a click after touchend.
126+
// Suppress that tap so swipe-up doesn't immediately trigger the tap path.
127+
this.ignoreNextClick = true;
128+
if (this.ignoreClickTimeoutId !== null) {
129+
globalThis.clearTimeout(this.ignoreClickTimeoutId);
130+
}
131+
this.ignoreClickTimeoutId = globalThis.setTimeout(() => {
132+
this.ignoreNextClick = false;
133+
this.ignoreClickTimeoutId = null;
134+
}, 400);
135+
117136
ecosystemEvents.emit('home:swipe-up', undefined);
118137

119138
// Dispatch custom event for React integration
@@ -128,6 +147,11 @@ export class HomeButton extends LitElement {
128147
};
129148

130149
private handleClick = (): void => {
150+
if (this.ignoreNextClick) {
151+
this.ignoreNextClick = false;
152+
return;
153+
}
154+
131155
ecosystemEvents.emit('home:tap', undefined);
132156

133157
this.dispatchEvent(

src/services/chain-adapter/providers/index.ts

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export { MoralisProviderEffect, createMoralisProviderEffect } from './moralis-pr
3030
import type { ApiProvider, ApiProviderFactory } from './types';
3131
import type { ParsedApiEntry } from '@/services/chain-config';
3232
import { chainConfigService } from '@/services/chain-config/service';
33+
import { chainConfigStore } from '@/stores/chain-config';
3334
import { ChainProvider } from './chain-provider';
3435

3536
import { createEtherscanV1ProviderEffect } from './etherscan-v1-provider.effect';
@@ -97,19 +98,44 @@ export function createChainProvider(chainId: string): ChainProvider {
9798
}
9899

99100
/** ChainProvider 缓存 */
100-
const providerCache = new Map<string, ChainProvider>();
101+
type ProviderCacheEntry = {
102+
provider: ChainProvider;
103+
apiKey: string;
104+
};
105+
106+
const providerCache = new Map<string, ProviderCacheEntry>();
107+
108+
function createApiKey(entries: ParsedApiEntry[]): string {
109+
return entries
110+
.map((entry) => {
111+
const configKey = entry.config ? JSON.stringify(entry.config) : "";
112+
return `${entry.type}|${entry.endpoint}|${configKey}`;
113+
})
114+
.join(",");
115+
}
101116

102117
/**
103118
* 获取或创建 ChainProvider(带缓存)
104119
*/
105120
export function getChainProvider(chainId: string): ChainProvider {
106121
const resolvedChainId = resolveChainId(chainId);
107122

108-
let provider = providerCache.get(resolvedChainId);
109-
if (!provider) {
110-
provider = createChainProvider(resolvedChainId);
111-
providerCache.set(resolvedChainId, provider);
123+
// Avoid caching providers before chain configs are initialized.
124+
// Otherwise `entries=[]` would create an empty provider and poison the cache.
125+
if (!chainConfigStore.state.snapshot) {
126+
return createChainProvider(resolvedChainId);
127+
}
128+
129+
const entries = chainConfigService.getApi(resolvedChainId);
130+
const apiKey = createApiKey(entries);
131+
132+
const cached = providerCache.get(resolvedChainId);
133+
if (cached && cached.apiKey === apiKey) {
134+
return cached.provider;
112135
}
136+
137+
const provider = createChainProvider(resolvedChainId);
138+
providerCache.set(resolvedChainId, { provider, apiKey });
113139
return provider;
114140
}
115141

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { beforeEach, describe, expect, it } from 'vitest'
2+
import 'fake-indexeddb/auto'
3+
4+
import { resetChainConfigStorageForTests } from '@/services/chain-config/storage'
5+
import { chainConfigActions, chainConfigStore } from '@/stores/chain-config'
6+
7+
import { clearProviderCache, getChainProvider } from './index'
8+
9+
describe('getChainProvider cache safety', () => {
10+
beforeEach(async () => {
11+
await resetChainConfigStorageForTests()
12+
clearProviderCache()
13+
chainConfigStore.setState(() => ({
14+
snapshot: null,
15+
isLoading: false,
16+
error: null,
17+
migrationRequired: false,
18+
}))
19+
})
20+
21+
it('does not cache an empty provider before chain configs initialize', async () => {
22+
const provider1 = getChainProvider('bfmetav2')
23+
const provider2 = getChainProvider('bfmetav2')
24+
25+
expect(provider1).not.toBe(provider2)
26+
expect(provider1.supportsFullTransaction).toBe(false)
27+
28+
await chainConfigActions.initialize()
29+
30+
const provider3 = getChainProvider('bfmetav2')
31+
const provider4 = getChainProvider('bfmetav2')
32+
33+
expect(provider3).toBe(provider4)
34+
expect(provider3).not.toBe(provider1)
35+
expect(provider3.supportsFullTransaction).toBe(true)
36+
})
37+
38+
it('rebuilds the cached provider when api entries change', async () => {
39+
await chainConfigActions.initialize()
40+
41+
const originalSnapshot = chainConfigStore.state.snapshot
42+
expect(originalSnapshot).not.toBeNull()
43+
if (!originalSnapshot) return
44+
45+
const originalConfig = originalSnapshot.configs.find((config) => config.id === 'bfmetav2')
46+
expect(originalConfig).toBeTruthy()
47+
if (!originalConfig) return
48+
49+
chainConfigStore.setState((state) => {
50+
const snapshot = state.snapshot
51+
if (!snapshot) return state
52+
53+
return {
54+
...state,
55+
snapshot: {
56+
...snapshot,
57+
configs: snapshot.configs.map((config) =>
58+
config.id === originalConfig.id
59+
? {
60+
...config,
61+
apis: [],
62+
}
63+
: config,
64+
),
65+
},
66+
}
67+
})
68+
69+
clearProviderCache()
70+
71+
const provider1 = getChainProvider('bfmetav2')
72+
expect(provider1.supportsFullTransaction).toBe(false)
73+
74+
chainConfigStore.setState((state) => {
75+
const snapshot = state.snapshot
76+
if (!snapshot) return state
77+
78+
return {
79+
...state,
80+
snapshot: {
81+
...snapshot,
82+
configs: snapshot.configs.map((config) =>
83+
config.id === originalConfig.id ? originalConfig : config,
84+
),
85+
},
86+
}
87+
})
88+
89+
const provider2 = getChainProvider('bfmetav2')
90+
expect(provider2).not.toBe(provider1)
91+
expect(provider2.supportsFullTransaction).toBe(true)
92+
})
93+
})
94+

src/services/ecosystem/handlers/destroy.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import type { MethodHandler, EcosystemDestroyParams } from '../types'
66
import { BioErrorCodes } from '../types'
77
import { HandlerContext, type MiniappInfo, toMiniappInfo } from './context'
8+
import { enqueueMiniappSheet } from '../sheet-queue'
89

910
// 兼容旧 API
1011
let _showDestroyDialog: ((params: EcosystemDestroyParams & { app: MiniappInfo }) => Promise<{ txHash: string } | null>) | null = null
@@ -43,7 +44,7 @@ export const handleDestroyAsset: MethodHandler = async (params, context) => {
4344
app: toMiniappInfo(context),
4445
}
4546

46-
const result = await showDestroyDialog(destroyParams)
47+
const result = await enqueueMiniappSheet(context.appId, () => showDestroyDialog(destroyParams))
4748

4849
if (!result) {
4950
throw Object.assign(new Error('User rejected'), { code: BioErrorCodes.USER_REJECTED })

src/services/ecosystem/handlers/evm.ts

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import type { MethodHandler, BioAccount } from '../types'
99
import { BioErrorCodes } from '../types'
1010
import { HandlerContext } from './context'
11+
import { enqueueMiniappSheet } from '../sheet-queue'
1112
import {
1213
toHexChainId,
1314
parseHexChainId,
@@ -132,7 +133,10 @@ export const handleEthRequestAccounts: MethodHandler = async (_params, context)
132133
}
133134

134135
const chainId = getCurrentChainId(context.appId)
135-
const wallet = await showWalletPicker({ chainId, app: { name: context.appName, icon: context.appIcon } })
136+
137+
const wallet = await enqueueMiniappSheet(context.appId, () =>
138+
showWalletPicker({ chainId, app: { name: context.appName, icon: context.appIcon } }),
139+
)
136140
if (!wallet) {
137141
throw Object.assign(new Error('User rejected'), { code: BioErrorCodes.USER_REJECTED })
138142
}
@@ -173,12 +177,14 @@ export const handleSwitchChain: MethodHandler = async (params, context) => {
173177

174178
// Show confirmation dialog
175179
if (_showChainSwitchConfirm) {
176-
const approved = await _showChainSwitchConfirm({
177-
fromChainId: currentChainId,
178-
toChainId: targetChainId,
179-
appName: context.appName,
180-
appIcon: context.appIcon,
181-
})
180+
const approved = await enqueueMiniappSheet(context.appId, () =>
181+
_showChainSwitchConfirm({
182+
fromChainId: currentChainId,
183+
toChainId: targetChainId,
184+
appName: context.appName,
185+
appIcon: context.appIcon,
186+
}),
187+
)
182188
if (!approved) {
183189
throw Object.assign(new Error('User rejected'), { code: BioErrorCodes.USER_REJECTED })
184190
}
@@ -211,7 +217,9 @@ export const handlePersonalSign: MethodHandler = async (params, context) => {
211217
throw Object.assign(new Error('Signing dialog not available'), { code: BioErrorCodes.INTERNAL_ERROR })
212218
}
213219

214-
const result = await showSigningDialog({ message, address, appName: context.appName })
220+
const result = await enqueueMiniappSheet(context.appId, () =>
221+
showSigningDialog({ message, address, appName: context.appName }),
222+
)
215223
if (!result) {
216224
throw Object.assign(new Error('User rejected'), { code: BioErrorCodes.USER_REJECTED })
217225
}
@@ -243,11 +251,13 @@ export const handleSignTypedDataV4: MethodHandler = async (params, context) => {
243251
// Format typed data for display
244252
const displayMessage = JSON.stringify(data, null, 2)
245253

246-
const result = await showSigningDialog({
247-
message: displayMessage,
248-
address,
249-
appName: context.appName,
250-
})
254+
const result = await enqueueMiniappSheet(context.appId, () =>
255+
showSigningDialog({
256+
message: displayMessage,
257+
address,
258+
appName: context.appName,
259+
}),
260+
)
251261
if (!result) {
252262
throw Object.assign(new Error('User rejected'), { code: BioErrorCodes.USER_REJECTED })
253263
}
@@ -272,7 +282,7 @@ export const handleEthSendTransaction: MethodHandler = async (params, context) =
272282
tx.chainId = getCurrentChainId(context.appId)
273283
}
274284

275-
const result = await showTransactionDialog({ tx, appName: context.appName })
285+
const result = await enqueueMiniappSheet(context.appId, () => showTransactionDialog({ tx, appName: context.appName }))
276286
if (!result) {
277287
throw Object.assign(new Error('User rejected'), { code: BioErrorCodes.USER_REJECTED })
278288
}

src/services/ecosystem/handlers/signing.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import type { MethodHandler } from '../types'
66
import { BioErrorCodes } from '../types'
77
import { HandlerContext, type SigningParams, type SigningResult, toMiniappInfo } from './context'
8+
import { enqueueMiniappSheet } from '../sheet-queue'
89

910
// 兼容旧 API(现在返回 SigningResult)
1011
let _showSigningDialog: ((params: SigningParams) => Promise<SigningResult | null>) | null = null
@@ -32,12 +33,14 @@ export const handleSignMessage: MethodHandler = async (params, context) => {
3233
throw Object.assign(new Error('Signing dialog not available'), { code: BioErrorCodes.INTERNAL_ERROR })
3334
}
3435

35-
const result = await showSigningDialog({
36+
const dialogParams = {
3637
message: opts.message,
3738
address: opts.address,
3839
chainName: opts.chainName,
3940
app: toMiniappInfo(context),
40-
})
41+
}
42+
43+
const result = await enqueueMiniappSheet(context.appId, () => showSigningDialog(dialogParams))
4144

4245
if (!result) {
4346
throw Object.assign(new Error('User rejected'), { code: BioErrorCodes.USER_REJECTED })
@@ -62,12 +65,14 @@ export const handleSignTypedData: MethodHandler = async (params, context) => {
6265
// Convert typed data to readable message
6366
const message = JSON.stringify(opts.data, null, 2)
6467

65-
const result = await showSigningDialog({
68+
const dialogParams = {
6669
message,
6770
address: opts.address,
6871
chainName: opts.chainName,
6972
app: toMiniappInfo(context),
70-
})
73+
}
74+
75+
const result = await enqueueMiniappSheet(context.appId, () => showSigningDialog(dialogParams))
7176

7277
if (!result) {
7378
throw Object.assign(new Error('User rejected'), { code: BioErrorCodes.USER_REJECTED })

0 commit comments

Comments
 (0)