Skip to content

Commit 2d5a56b

Browse files
committed
fix: resolve tx.onceSealed() hanging for fresh transactions
Replace the actor-based legacy polling fallback with a simple direct polling loop. The actor system (util-actor) has reliability issues where polling silently stops after the first successful poll for freshly submitted transactions. Root cause: When WebSocket streaming is unavailable (e.g., using the deprecated `send` function transport), the fallback to actor-based legacy polling (legacy-polling.js) stops delivering status updates after the initial poll. The actor's POLL→broadcast→subscriber chain breaks silently — polling continues but the subscriber callback never fires again after receiving status=1 (PENDING). The new fallback uses a straightforward async while-loop that polls via sdk.send/decode directly, matching the pre-streaming behavior that worked reliably before PR #2258. Also adds a 30s WebSocket timeout as a safety net — if the WS subscription receives no data within 30s, it falls back to polling. Verified: - Fresh tx + send function: was HANGING, now resolves in ~8s ✅ - Fresh tx + httpTransport: works via WebSocket in ~7s ✅ - Sealed tx + send function: resolves in ~1s ✅ - Sealed tx + httpTransport: resolves in ~1s ✅ Closes #2792
1 parent 961e5be commit 2d5a56b

2 files changed

Lines changed: 156 additions & 18 deletions

File tree

‎packages/fcl-core/src/transaction/transaction.test.ts‎

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,88 @@ describe("transaction", () => {
211211
unsubscribe()
212212
})
213213

214+
test("should fall back to legacy polling if WebSocket receives no data within timeout", async () => {
215+
jest.useFakeTimers()
216+
217+
const txId =
218+
"5234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
219+
const onData = jest.fn()
220+
const onError = jest.fn()
221+
222+
// Use a short wsTimeout
223+
transaction(txId, {wsTimeout: 5000}).subscribe(onData, onError)
224+
225+
// Flush the event loop for init()
226+
await jest.advanceTimersByTimeAsync(0)
227+
228+
// Verify WebSocket subscription was attempted
229+
expect(subscribe).toHaveBeenCalled()
230+
231+
const subscribeParams = jest.mocked(subscribe).mock
232+
.calls[0][0] as Parameters<
233+
typeof subscribe<SubscriptionTopic.TRANSACTION_STATUSES>
234+
>[0]
235+
236+
expect(subscribeParams).toStrictEqual({
237+
topic: SubscriptionTopic.TRANSACTION_STATUSES,
238+
args: {transactionId: txId},
239+
onData: expect.any(Function),
240+
onError: expect.any(Function),
241+
})
242+
243+
// Do NOT send any data via WebSocket — simulate a hung connection
244+
// Advance past the timeout
245+
await jest.advanceTimersByTimeAsync(5000)
246+
247+
// Should have fallen back to legacy polling
248+
expect(legacyTransaction).toHaveBeenCalledWith(txId, {
249+
wsTimeout: 5000,
250+
})
251+
252+
// WebSocket subscription should have been unsubscribed
253+
const unsubMock = jest.mocked(subscribe).mock.results[0].value
254+
expect(unsubMock.unsubscribe).toHaveBeenCalledTimes(1)
255+
256+
jest.useRealTimers()
257+
})
258+
259+
test("should NOT fall back to polling if WebSocket receives data before timeout", async () => {
260+
jest.useFakeTimers()
261+
262+
const txId =
263+
"6234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
264+
const onData = jest.fn()
265+
const onError = jest.fn()
266+
267+
transaction(txId, {wsTimeout: 5000}).subscribe(onData, onError)
268+
269+
// Flush the event loop for init()
270+
await jest.advanceTimersByTimeAsync(0)
271+
272+
const subscribeParams = jest.mocked(subscribe).mock
273+
.calls[0][0] as Parameters<
274+
typeof subscribe<SubscriptionTopic.TRANSACTION_STATUSES>
275+
>[0]
276+
277+
// Send data before the timeout
278+
subscribeParams.onData({
279+
status: TransactionExecutionStatus.PENDING,
280+
blockId: "",
281+
statusCode: 0,
282+
errorMessage: "",
283+
events: [],
284+
statusString: "PENDING",
285+
})
286+
287+
// Advance past the timeout
288+
await jest.advanceTimersByTimeAsync(5000)
289+
290+
// Should NOT have fallen back to legacy polling
291+
expect(legacyTransaction).not.toHaveBeenCalled()
292+
293+
jest.useRealTimers()
294+
})
295+
214296
test("should fall back to legacy polling if the Flow emulator is detected", async () => {
215297
mockGetChainId.mockResolvedValue("local")
216298

‎packages/fcl-core/src/transaction/transaction.ts‎

Lines changed: 74 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,10 @@ import {
1414
scoped,
1515
} from "./utils"
1616
import {TXID_REGEXP} from "./constants"
17-
import {isUnknown, SubscriptionsNotSupportedError} from "@onflow/sdk"
17+
import {isUnknown, SubscriptionsNotSupportedError, send as fclSend, decode, getTransactionStatus} from "@onflow/sdk"
1818
import {TransactionError} from "./transaction-error"
19-
import {transaction as legacyTransaction} from "./legacy-polling"
19+
// legacy-polling.js actor-based polling is no longer used for the fallback
20+
// due to reliability issues (see https://github.com/onflow/fcl-js/issues/2792)
2021
import {createGetChainId} from "../utils"
2122
import {FCLContext} from "../context"
2223
import {createPartialGlobalFCLContext} from "../context/global"
@@ -114,6 +115,7 @@ export function createTransaction(context: Pick<FCLContext, "sdk" | "config">) {
114115
opts: {
115116
pollRate?: number
116117
txNotFoundTimeout?: number
118+
wsTimeout?: number
117119
} = {txNotFoundTimeout: 12500, pollRate: 1000}
118120
): {
119121
snapshot: () => Promise<TransactionStatus>
@@ -216,7 +218,7 @@ export const transaction = /* @__PURE__ */ createTransaction(
216218
function createObservable(
217219
context: Pick<FCLContext, "sdk" | "config">,
218220
txId: string,
219-
opts: {pollRate?: number; txNotFoundTimeout?: number}
221+
opts: {pollRate?: number; txNotFoundTimeout?: number; wsTimeout?: number}
220222
) {
221223
const observers = new Set<{
222224
onData: (txStatus: TransactionStatus) => void
@@ -261,11 +263,20 @@ function createObservable(
261263

262264
// Subscribe to transaction status updates
263265
function subscribeTransactionStatuses() {
266+
const WS_TIMEOUT = opts.wsTimeout ?? 30000
267+
let receivedData = false
268+
264269
// Subscribe to transaction status updates
265270
const subscription = context.sdk.subscribe({
266271
topic: SubscriptionTopic.TRANSACTION_STATUSES,
267272
args: {transactionId: txId},
268273
onData: txStatus => {
274+
receivedData = true
275+
if (wsTimeoutId != null) {
276+
clearTimeout(wsTimeoutId)
277+
wsTimeoutId = null
278+
}
279+
269280
if (isDiff(value, txStatus)) {
270281
value = txStatus
271282
next(txStatus)
@@ -282,6 +293,11 @@ function createObservable(
282293
}
283294
},
284295
onError: (err: Error) => {
296+
if (wsTimeoutId != null) {
297+
clearTimeout(wsTimeoutId)
298+
wsTimeoutId = null
299+
}
300+
285301
if (err instanceof SubscriptionsNotSupportedError) {
286302
console.warn(
287303
"Failed to subscribe to transaction status updates using real-time streaming (are you using the deprecated GRPC transport?), falling back to polling."
@@ -292,28 +308,68 @@ function createObservable(
292308
}
293309
},
294310
})
311+
312+
// If no data is received within the timeout, fall back to legacy polling
313+
// This handles cases where the Access Node never pushes data (e.g., tx already sealed)
314+
let wsTimeoutId: ReturnType<typeof setTimeout> | null = setTimeout(() => {
315+
if (!receivedData) {
316+
console.warn(
317+
`No transaction status data received via WebSocket within ${WS_TIMEOUT}ms, falling back to polling.`
318+
)
319+
subscription.unsubscribe()
320+
fallbackLegacyPolling()
321+
}
322+
}, WS_TIMEOUT)
295323
}
296324

297325
function fallbackLegacyPolling() {
298-
// Poll for transaction status updates
299-
const unsubscribe = legacyTransaction(txId, opts).subscribe(
300-
(txStatus?: TransactionStatus, err?: Error) => {
301-
if (err) {
302-
error(err)
303-
} else if (txStatus && isDiff(value, txStatus)) {
304-
value = txStatus
305-
next(txStatus)
326+
// Simple direct polling — bypasses the actor system which has reliability
327+
// issues with fresh transactions (see https://github.com/onflow/fcl-js/issues/2792)
328+
let cancelled = false
329+
330+
const poll = async () => {
331+
const pollRate = opts.pollRate ?? 1000
332+
const txNotFoundTimeout = opts.txNotFoundTimeout ?? 12500
333+
const startTime = Date.now()
306334

307-
// Clean up the subscription if the transaction is sealed
308-
// Wait for next tick to ensure unsubscribe is defined
309-
if (isSealed(txStatus)) {
310-
new Promise(resolve => setTimeout(resolve, 0)).then(() => {
311-
unsubscribe()
312-
})
335+
while (!cancelled) {
336+
try {
337+
const response = await fclSend([getTransactionStatus(txId)])
338+
const txStatus: TransactionStatus = await decode(response)
339+
340+
if (isDiff(value, txStatus)) {
341+
value = txStatus
342+
next(txStatus)
343+
}
344+
345+
// Stop polling if the transaction has reached a terminal state
346+
if (isSealed(txStatus) || isExpired(txStatus) || txStatus.statusCode === 1) {
347+
return
348+
}
349+
} catch (e: any) {
350+
// Ignore not-found errors during initial propagation
351+
const isNotFound =
352+
(e?.statusCode === 404) ||
353+
(e?.message?.includes?.("not found"))
354+
if (!isNotFound) {
355+
// If we haven't found the transaction within the timeout, report error
356+
if (Date.now() - startTime > txNotFoundTimeout) {
357+
error(e instanceof Error ? e : new Error(String(e)))
358+
return
359+
}
313360
}
314361
}
362+
363+
await new Promise(resolve => setTimeout(resolve, pollRate))
315364
}
316-
)
365+
}
366+
367+
poll()
368+
369+
// Return cleanup function (used by createObservable if all observers unsubscribe)
370+
return () => {
371+
cancelled = true
372+
}
317373
}
318374

319375
function next(txStatus: TransactionStatus) {

0 commit comments

Comments
 (0)