@skopon-cool/form-sdk 0.3.0 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/catalog/useSkoponBoundField.d.ts.map +1 -1
- package/dist/components/AskUserFormCard.d.ts.map +1 -1
- package/dist/index.js +1024 -995
- package/dist/runtime/formAutoControl.d.ts +1 -0
- package/dist/runtime/formAutoControl.d.ts.map +1 -1
- package/dist/runtime/formRuntimeContext.d.ts +4 -0
- package/dist/runtime/formRuntimeContext.d.ts.map +1 -1
- package/dist/runtime/formRuntimeWs.d.ts +2 -0
- package/dist/runtime/formRuntimeWs.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/catalog/useSkoponBoundField.ts +21 -0
- package/src/components/AskUserFormCard.tsx +33 -22
- package/src/runtime/formAutoControl.test.ts +56 -0
- package/src/runtime/formAutoControl.ts +3 -2
- package/src/runtime/formRuntimeContext.tsx +24 -2
- package/src/runtime/formRuntimeWs.ts +17 -12
|
@@ -8,6 +8,7 @@ export interface FormAutoControlTargets {
|
|
|
8
8
|
}
|
|
9
9
|
export interface FormAutoControlHelpers {
|
|
10
10
|
reportEvent: (event: FormRuntimeEventData) => void;
|
|
11
|
+
/** 支持同步或返回 Promise(async submit 期间保持 autoControlDepth) */
|
|
11
12
|
runAutoControl: <T>(fn: () => T) => T;
|
|
12
13
|
}
|
|
13
14
|
/** 执行下行 form_auto_control_event;缺参或未知 action 时 warn 并忽略。 */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"formAutoControl.d.ts","sourceRoot":"","sources":["../../src/runtime/formAutoControl.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,wBAAwB,EAAE,oBAAoB,EAAE,MAAM,SAAS,CAAA;AAE7E,MAAM,WAAW,sBAAsB;IACrC,SAAS,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAA;IACpD,SAAS,EAAE,CAAC,UAAU,CAAC,EAAE,MAAM,EAAE,KAAK,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IAC7D,MAAM,EAAE,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAClC,yCAAyC;IACzC,WAAW,EAAE,WAAW,GAAG,IAAI,CAAA;CAChC;AAED,MAAM,WAAW,sBAAsB;IACrC,WAAW,EAAE,CAAC,KAAK,EAAE,oBAAoB,KAAK,IAAI,CAAA;IAClD,cAAc,EAAE,CAAC,CAAC,EAAE,EAAE,EAAE,MAAM,CAAC,KAAK,CAAC,CAAA;CACtC;AAiCD,4DAA4D;AAC5D,wBAAgB,sBAAsB,CACpC,IAAI,EAAE,wBAAwB,EAC9B,OAAO,EAAE,sBAAsB,EAC/B,OAAO,EAAE,sBAAsB,GAC9B,IAAI,CAyDN"}
|
|
1
|
+
{"version":3,"file":"formAutoControl.d.ts","sourceRoot":"","sources":["../../src/runtime/formAutoControl.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,wBAAwB,EAAE,oBAAoB,EAAE,MAAM,SAAS,CAAA;AAE7E,MAAM,WAAW,sBAAsB;IACrC,SAAS,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAA;IACpD,SAAS,EAAE,CAAC,UAAU,CAAC,EAAE,MAAM,EAAE,KAAK,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IAC7D,MAAM,EAAE,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAClC,yCAAyC;IACzC,WAAW,EAAE,WAAW,GAAG,IAAI,CAAA;CAChC;AAED,MAAM,WAAW,sBAAsB;IACrC,WAAW,EAAE,CAAC,KAAK,EAAE,oBAAoB,KAAK,IAAI,CAAA;IAClD,0DAA0D;IAC1D,cAAc,EAAE,CAAC,CAAC,EAAE,EAAE,EAAE,MAAM,CAAC,KAAK,CAAC,CAAA;CACtC;AAiCD,4DAA4D;AAC5D,wBAAgB,sBAAsB,CACpC,IAAI,EAAE,wBAAwB,EAC9B,OAAO,EAAE,sBAAsB,EAC/B,OAAO,EAAE,sBAAsB,GAC9B,IAAI,CAyDN"}
|
|
@@ -4,6 +4,10 @@ import type { FormRuntimeEventData } from './types';
|
|
|
4
4
|
export interface FormRuntimeContextValue {
|
|
5
5
|
reportEvent: (event: FormRuntimeEventData) => void;
|
|
6
6
|
reportData: (values: Record<string, unknown>) => void;
|
|
7
|
+
/**
|
|
8
|
+
* 在自动控上下文中执行 fn。若 fn 返回 Promise,depth 保持到 settle,
|
|
9
|
+
* 使异步 submit 内的 reportEvent 仍为 form_auto_control_bac_event。
|
|
10
|
+
*/
|
|
7
11
|
runAutoControl: <T>(fn: () => T) => T;
|
|
8
12
|
isAutoControl: () => boolean;
|
|
9
13
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"formRuntimeContext.d.ts","sourceRoot":"","sources":["../../src/runtime/formRuntimeContext.tsx"],"names":[],"mappings":"AAAA,OAAO,EAML,KAAK,SAAS,EACf,MAAM,OAAO,CAAA;AACd,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,iBAAiB,CAAA;AAC1D,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,SAAS,CAAA;AAEnD,MAAM,WAAW,uBAAuB;IACtC,WAAW,EAAE,CAAC,KAAK,EAAE,oBAAoB,KAAK,IAAI,CAAA;IAClD,UAAU,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAA;IACrD,cAAc,EAAE,CAAC,CAAC,EAAE,EAAE,EAAE,MAAM,CAAC,KAAK,CAAC,CAAA;IACrC,aAAa,EAAE,MAAM,OAAO,CAAA;CAC7B;
|
|
1
|
+
{"version":3,"file":"formRuntimeContext.d.ts","sourceRoot":"","sources":["../../src/runtime/formRuntimeContext.tsx"],"names":[],"mappings":"AAAA,OAAO,EAML,KAAK,SAAS,EACf,MAAM,OAAO,CAAA;AACd,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,iBAAiB,CAAA;AAC1D,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,SAAS,CAAA;AAEnD,MAAM,WAAW,uBAAuB;IACtC,WAAW,EAAE,CAAC,KAAK,EAAE,oBAAoB,KAAK,IAAI,CAAA;IAClD,UAAU,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAA;IACrD;;;OAGG;IACH,cAAc,EAAE,CAAC,CAAC,EAAE,EAAE,EAAE,MAAM,CAAC,KAAK,CAAC,CAAA;IACrC,aAAa,EAAE,MAAM,OAAO,CAAA;CAC7B;AA6BD,MAAM,WAAW,wBAAwB;IACvC,MAAM,EAAE,mBAAmB,GAAG,IAAI,CAAA;IAClC,QAAQ,EAAE,SAAS,CAAA;CACpB;AAED,wBAAgB,mBAAmB,CAAC,EAClC,MAAM,EACN,QAAQ,GACT,EAAE,wBAAwB,+BAkE1B;AAED,uCAAuC;AACvC,wBAAgB,cAAc,IAAI,uBAAuB,CAExD"}
|
|
@@ -18,6 +18,8 @@ export declare class FormRuntimeWsClient {
|
|
|
18
18
|
connect(): void;
|
|
19
19
|
send(type: FormRuntimeOutboundType, data: Record<string, unknown>): void;
|
|
20
20
|
close(): void;
|
|
21
|
+
/** 关闭当前 socket 且不触发重连(清掉 onclose),建新连或主动 close 时用。 */
|
|
22
|
+
private disposeSocket;
|
|
21
23
|
private enqueue;
|
|
22
24
|
private flushQueue;
|
|
23
25
|
private scheduleReconnect;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"formRuntimeWs.d.ts","sourceRoot":"","sources":["../../src/runtime/formRuntimeWs.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,wBAAwB,EACxB,mBAAmB,EACnB,uBAAuB,EACxB,MAAM,SAAS,CAAA;AAKhB,MAAM,MAAM,sBAAsB,GAAG,CACnC,IAAI,EAAE,wBAAwB,EAC9B,QAAQ,EAAE,mBAAmB,CAAC,wBAAwB,CAAC,KACpD,IAAI,CAAA;AAET,MAAM,WAAW,0BAA0B;IACzC,GAAG,EAAE,MAAM,CAAA;IACX,gBAAgB,EAAE,MAAM,CAAA;IACxB,aAAa,CAAC,EAAE,sBAAsB,CAAA;CACvC;AAED,qBAAa,mBAAmB;IAC9B,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAQ;IAC5B,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAQ;IACzC,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAC,CAAwB;IACvD,OAAO,CAAC,EAAE,CAAyB;IACnC,OAAO,CAAC,MAAM,CAAQ;IACtB,OAAO,CAAC,gBAAgB,CAAI;IAC5B,OAAO,CAAC,cAAc,CAA6C;IACnE,OAAO,CAAC,SAAS,CAAe;gBAEpB,OAAO,EAAE,0BAA0B;IAM/C,OAAO,IAAI,IAAI;
|
|
1
|
+
{"version":3,"file":"formRuntimeWs.d.ts","sourceRoot":"","sources":["../../src/runtime/formRuntimeWs.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,wBAAwB,EACxB,mBAAmB,EACnB,uBAAuB,EACxB,MAAM,SAAS,CAAA;AAKhB,MAAM,MAAM,sBAAsB,GAAG,CACnC,IAAI,EAAE,wBAAwB,EAC9B,QAAQ,EAAE,mBAAmB,CAAC,wBAAwB,CAAC,KACpD,IAAI,CAAA;AAET,MAAM,WAAW,0BAA0B;IACzC,GAAG,EAAE,MAAM,CAAA;IACX,gBAAgB,EAAE,MAAM,CAAA;IACxB,aAAa,CAAC,EAAE,sBAAsB,CAAA;CACvC;AAED,qBAAa,mBAAmB;IAC9B,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAQ;IAC5B,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAQ;IACzC,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAC,CAAwB;IACvD,OAAO,CAAC,EAAE,CAAyB;IACnC,OAAO,CAAC,MAAM,CAAQ;IACtB,OAAO,CAAC,gBAAgB,CAAI;IAC5B,OAAO,CAAC,cAAc,CAA6C;IACnE,OAAO,CAAC,SAAS,CAAe;gBAEpB,OAAO,EAAE,0BAA0B;IAM/C,OAAO,IAAI,IAAI;IA2Bf,IAAI,CACF,IAAI,EAAE,uBAAuB,EAC7B,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC5B,IAAI;IAqBP,KAAK,IAAI,IAAI;IAOb,sDAAsD;IACtD,OAAO,CAAC,aAAa;IAcrB,OAAO,CAAC,OAAO;IAOf,OAAO,CAAC,UAAU;IASlB,OAAO,CAAC,iBAAiB;IAczB,OAAO,CAAC,mBAAmB;IAO3B,OAAO,CAAC,aAAa;CAgBtB"}
|
package/package.json
CHANGED
|
@@ -59,8 +59,16 @@ export function useSkoponBoundField(
|
|
|
59
59
|
} = options
|
|
60
60
|
|
|
61
61
|
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
62
|
+
const pendingDebouncedValueRef = useRef<unknown>(undefined)
|
|
63
|
+
const hasPendingDebouncedRef = useRef(false)
|
|
62
64
|
const runtimeRef = useRef(runtime)
|
|
63
65
|
runtimeRef.current = runtime
|
|
66
|
+
const changeActionRef = useRef(changeAction)
|
|
67
|
+
changeActionRef.current = changeAction
|
|
68
|
+
const componentRef = useRef(component)
|
|
69
|
+
componentRef.current = component
|
|
70
|
+
const fieldNameRef = useRef(fieldName)
|
|
71
|
+
fieldNameRef.current = fieldName
|
|
64
72
|
|
|
65
73
|
useEffect(() => {
|
|
66
74
|
return () => {
|
|
@@ -68,6 +76,16 @@ export function useSkoponBoundField(
|
|
|
68
76
|
clearTimeout(debounceTimerRef.current)
|
|
69
77
|
debounceTimerRef.current = null
|
|
70
78
|
}
|
|
79
|
+
// unmount 时 flush 未发出的最后一次 change,避免丢事件
|
|
80
|
+
if (hasPendingDebouncedRef.current && fieldNameRef.current) {
|
|
81
|
+
hasPendingDebouncedRef.current = false
|
|
82
|
+
runtimeRef.current.reportEvent({
|
|
83
|
+
action: changeActionRef.current,
|
|
84
|
+
component: componentRef.current,
|
|
85
|
+
field: fieldNameRef.current,
|
|
86
|
+
value: pendingDebouncedValueRef.current,
|
|
87
|
+
})
|
|
88
|
+
}
|
|
71
89
|
}
|
|
72
90
|
}, [])
|
|
73
91
|
|
|
@@ -91,6 +109,7 @@ export function useSkoponBoundField(
|
|
|
91
109
|
(next: unknown) => {
|
|
92
110
|
if (!fieldName) return
|
|
93
111
|
const emit = () => {
|
|
112
|
+
hasPendingDebouncedRef.current = false
|
|
94
113
|
runtimeRef.current.reportEvent({
|
|
95
114
|
action: changeAction,
|
|
96
115
|
component,
|
|
@@ -102,6 +121,8 @@ export function useSkoponBoundField(
|
|
|
102
121
|
emit()
|
|
103
122
|
return
|
|
104
123
|
}
|
|
124
|
+
pendingDebouncedValueRef.current = next
|
|
125
|
+
hasPendingDebouncedRef.current = true
|
|
105
126
|
if (debounceTimerRef.current != null) {
|
|
106
127
|
clearTimeout(debounceTimerRef.current)
|
|
107
128
|
}
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
import { buildAskUserSurface } from '../adapter/resolveSurface'
|
|
9
9
|
import {
|
|
10
10
|
buildAnswerAgentBody,
|
|
11
|
+
validateAnswerAgentBody,
|
|
11
12
|
type AskUserAnswerAgentContext,
|
|
12
13
|
} from '../submit/buildAnswerAgentBody'
|
|
13
14
|
import {
|
|
@@ -222,18 +223,18 @@ function AskUserFormCardBody({
|
|
|
222
223
|
}
|
|
223
224
|
const answer = { ...cardValues, ...extraValues }
|
|
224
225
|
|
|
225
|
-
runtime.reportData(answer)
|
|
226
|
-
runtime.reportEvent({
|
|
227
|
-
action: 'submit',
|
|
228
|
-
component: 'SubmitButton',
|
|
229
|
-
})
|
|
230
|
-
|
|
231
226
|
if (submitMode === 'post') {
|
|
232
227
|
const url = (callbackUrl ?? '').trim()
|
|
233
228
|
if (!url) {
|
|
234
229
|
onNotifyRef.current?.('error', 'callback_url 为空,无法提交')
|
|
235
230
|
return
|
|
236
231
|
}
|
|
232
|
+
// 确认可 POST 后再上报,避免空 callback 仍发 data/submit
|
|
233
|
+
runtime.reportData(answer)
|
|
234
|
+
runtime.reportEvent({
|
|
235
|
+
action: 'submit',
|
|
236
|
+
component: 'SubmitButton',
|
|
237
|
+
})
|
|
237
238
|
const result = await submitFormJson(url, answer, {
|
|
238
239
|
headers: resolvedSubmitHeaders,
|
|
239
240
|
})
|
|
@@ -252,6 +253,16 @@ function AskUserFormCardBody({
|
|
|
252
253
|
form: formDef ?? payloadDef,
|
|
253
254
|
answer,
|
|
254
255
|
})
|
|
256
|
+
const agentError = validateAnswerAgentBody(body)
|
|
257
|
+
if (agentError) {
|
|
258
|
+
onNotifyRef.current?.('error', agentError)
|
|
259
|
+
return
|
|
260
|
+
}
|
|
261
|
+
runtime.reportData(answer)
|
|
262
|
+
runtime.reportEvent({
|
|
263
|
+
action: 'submit',
|
|
264
|
+
component: 'SubmitButton',
|
|
265
|
+
})
|
|
255
266
|
const result = await submitAnswerAgent(body, {
|
|
256
267
|
headers: resolvedSubmitHeaders,
|
|
257
268
|
})
|
|
@@ -383,7 +394,6 @@ export default function AskUserFormCard(props: AskUserFormCardProps) {
|
|
|
383
394
|
const resolvedAskUserMessageId = props.askUserMessageId?.trim() ?? ''
|
|
384
395
|
const realtimeWsUrl = useMemo(() => resolveRealtimeWsUrl(), [])
|
|
385
396
|
const [surfaceReady, setSurfaceReady] = useState(false)
|
|
386
|
-
const [wsClient, setWsClient] = useState<FormRuntimeWsClient | null>(null)
|
|
387
397
|
const autoControlHandlerRef = useRef<((data: FormAutoControlEventData) => void) | null>(null)
|
|
388
398
|
|
|
389
399
|
const onSurfaceReadyChange = useCallback((ready: boolean) => {
|
|
@@ -397,29 +407,30 @@ export default function AskUserFormCard(props: AskUserFormCardProps) {
|
|
|
397
407
|
[],
|
|
398
408
|
)
|
|
399
409
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
)
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
if (!shouldConnect || !realtimeWsUrl || !resolvedAskUserMessageId) {
|
|
406
|
-
setWsClient(null)
|
|
407
|
-
return
|
|
408
|
-
}
|
|
409
|
-
const client = new FormRuntimeWsClient({
|
|
410
|
+
// 有 URL + id 即创建 client 并挂到 Provider:表单刚可交互时 send 可入队,
|
|
411
|
+
// 不必等 setState(surfaceReady) / connect 完成。
|
|
412
|
+
const wsClient = useMemo(() => {
|
|
413
|
+
if (!realtimeWsUrl || !resolvedAskUserMessageId) return null
|
|
414
|
+
return new FormRuntimeWsClient({
|
|
410
415
|
url: realtimeWsUrl,
|
|
411
416
|
askUserMessageId: resolvedAskUserMessageId,
|
|
412
417
|
onAutoControl: (data) => {
|
|
413
418
|
autoControlHandlerRef.current?.(data)
|
|
414
419
|
},
|
|
415
420
|
})
|
|
416
|
-
|
|
417
|
-
|
|
421
|
+
}, [realtimeWsUrl, resolvedAskUserMessageId])
|
|
422
|
+
|
|
423
|
+
useEffect(() => {
|
|
424
|
+
if (!wsClient) return
|
|
418
425
|
return () => {
|
|
419
|
-
|
|
420
|
-
setWsClient(null)
|
|
426
|
+
wsClient.close()
|
|
421
427
|
}
|
|
422
|
-
}, [
|
|
428
|
+
}, [wsClient])
|
|
429
|
+
|
|
430
|
+
useEffect(() => {
|
|
431
|
+
if (!wsClient || !surfaceReady) return
|
|
432
|
+
wsClient.connect()
|
|
433
|
+
}, [wsClient, surfaceReady])
|
|
423
434
|
|
|
424
435
|
return (
|
|
425
436
|
<SkoponFormProvider>
|
|
@@ -5,6 +5,32 @@ import { describe, it, expect, vi } from 'vitest'
|
|
|
5
5
|
import { executeFormAutoControl } from './formAutoControl'
|
|
6
6
|
import type { FormAutoControlEventData } from './types'
|
|
7
7
|
|
|
8
|
+
/** 模拟 FormRuntimeProvider.runAutoControl:Promise settle 前保持 depth。 */
|
|
9
|
+
function createDepthTrackingRunAutoControl(depthRef: { current: number }) {
|
|
10
|
+
return <T,>(fn: () => T): T => {
|
|
11
|
+
depthRef.current += 1
|
|
12
|
+
let syncResult: T
|
|
13
|
+
try {
|
|
14
|
+
syncResult = fn()
|
|
15
|
+
} catch (error) {
|
|
16
|
+
depthRef.current = Math.max(0, depthRef.current - 1)
|
|
17
|
+
throw error
|
|
18
|
+
}
|
|
19
|
+
if (
|
|
20
|
+
syncResult != null &&
|
|
21
|
+
(typeof syncResult === 'object' || typeof syncResult === 'function') &&
|
|
22
|
+
typeof (syncResult as PromiseLike<unknown>).then === 'function'
|
|
23
|
+
) {
|
|
24
|
+
const promise = Promise.resolve(syncResult).finally(() => {
|
|
25
|
+
depthRef.current = Math.max(0, depthRef.current - 1)
|
|
26
|
+
})
|
|
27
|
+
return promise as T
|
|
28
|
+
}
|
|
29
|
+
depthRef.current = Math.max(0, depthRef.current - 1)
|
|
30
|
+
return syncResult
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
8
34
|
describe('executeFormAutoControl', () => {
|
|
9
35
|
it('writes values and reports bac change event', () => {
|
|
10
36
|
const setValues = vi.fn()
|
|
@@ -68,4 +94,34 @@ describe('executeFormAutoControl', () => {
|
|
|
68
94
|
)
|
|
69
95
|
expect(submit).toHaveBeenCalled()
|
|
70
96
|
})
|
|
97
|
+
|
|
98
|
+
it('keeps autoControl depth until async submit settles', async () => {
|
|
99
|
+
const depthRef = { current: 0 }
|
|
100
|
+
const runAutoControl = createDepthTrackingRunAutoControl(depthRef)
|
|
101
|
+
let resolveSubmit!: () => void
|
|
102
|
+
const submit = vi.fn(
|
|
103
|
+
() =>
|
|
104
|
+
new Promise<void>((resolve) => {
|
|
105
|
+
resolveSubmit = resolve
|
|
106
|
+
}),
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
executeFormAutoControl(
|
|
110
|
+
{ action: 'submit' },
|
|
111
|
+
{
|
|
112
|
+
setValues: vi.fn(),
|
|
113
|
+
getValues: () => ({}),
|
|
114
|
+
submit,
|
|
115
|
+
rootElement: null,
|
|
116
|
+
},
|
|
117
|
+
{ reportEvent: vi.fn(), runAutoControl },
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
expect(submit).toHaveBeenCalled()
|
|
121
|
+
expect(depthRef.current).toBe(1)
|
|
122
|
+
resolveSubmit()
|
|
123
|
+
await Promise.resolve()
|
|
124
|
+
await Promise.resolve()
|
|
125
|
+
expect(depthRef.current).toBe(0)
|
|
126
|
+
})
|
|
71
127
|
})
|
|
@@ -10,6 +10,7 @@ export interface FormAutoControlTargets {
|
|
|
10
10
|
|
|
11
11
|
export interface FormAutoControlHelpers {
|
|
12
12
|
reportEvent: (event: FormRuntimeEventData) => void
|
|
13
|
+
/** 支持同步或返回 Promise(async submit 期间保持 autoControlDepth) */
|
|
13
14
|
runAutoControl: <T>(fn: () => T) => T
|
|
14
15
|
}
|
|
15
16
|
|
|
@@ -64,8 +65,8 @@ export function executeFormAutoControl(
|
|
|
64
65
|
|
|
65
66
|
helpers.runAutoControl(() => {
|
|
66
67
|
if (action === 'submit') {
|
|
67
|
-
|
|
68
|
-
return
|
|
68
|
+
// 返回 Promise,使 runAutoControl 在 settle 前保持 depth
|
|
69
|
+
return targets.submit()
|
|
69
70
|
}
|
|
70
71
|
|
|
71
72
|
const field = typeof data.field === 'string' ? data.field.trim() : ''
|
|
@@ -12,12 +12,24 @@ import type { FormRuntimeEventData } from './types'
|
|
|
12
12
|
export interface FormRuntimeContextValue {
|
|
13
13
|
reportEvent: (event: FormRuntimeEventData) => void
|
|
14
14
|
reportData: (values: Record<string, unknown>) => void
|
|
15
|
+
/**
|
|
16
|
+
* 在自动控上下文中执行 fn。若 fn 返回 Promise,depth 保持到 settle,
|
|
17
|
+
* 使异步 submit 内的 reportEvent 仍为 form_auto_control_bac_event。
|
|
18
|
+
*/
|
|
15
19
|
runAutoControl: <T>(fn: () => T) => T
|
|
16
20
|
isAutoControl: () => boolean
|
|
17
21
|
}
|
|
18
22
|
|
|
19
23
|
const FormRuntimeContext = createContext<FormRuntimeContextValue | null>(null)
|
|
20
24
|
|
|
25
|
+
function isThenable(value: unknown): value is PromiseLike<unknown> {
|
|
26
|
+
return (
|
|
27
|
+
value != null &&
|
|
28
|
+
(typeof value === 'object' || typeof value === 'function') &&
|
|
29
|
+
typeof (value as PromiseLike<unknown>).then === 'function'
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
|
|
21
33
|
const noopRuntime: FormRuntimeContextValue = {
|
|
22
34
|
reportEvent: () => {},
|
|
23
35
|
reportData: () => {},
|
|
@@ -72,11 +84,21 @@ export function FormRuntimeProvider({
|
|
|
72
84
|
|
|
73
85
|
const runAutoControl = useCallback(<T,>(fn: () => T): T => {
|
|
74
86
|
autoControlDepthRef.current += 1
|
|
87
|
+
let syncResult: T
|
|
75
88
|
try {
|
|
76
|
-
|
|
77
|
-
}
|
|
89
|
+
syncResult = fn()
|
|
90
|
+
} catch (error) {
|
|
78
91
|
autoControlDepthRef.current = Math.max(0, autoControlDepthRef.current - 1)
|
|
92
|
+
throw error
|
|
93
|
+
}
|
|
94
|
+
if (isThenable(syncResult)) {
|
|
95
|
+
const promise = Promise.resolve(syncResult).finally(() => {
|
|
96
|
+
autoControlDepthRef.current = Math.max(0, autoControlDepthRef.current - 1)
|
|
97
|
+
})
|
|
98
|
+
return promise as T
|
|
79
99
|
}
|
|
100
|
+
autoControlDepthRef.current = Math.max(0, autoControlDepthRef.current - 1)
|
|
101
|
+
return syncResult
|
|
80
102
|
}, [])
|
|
81
103
|
|
|
82
104
|
const isAutoControl = useCallback(
|
|
@@ -37,6 +37,7 @@ export class FormRuntimeWsClient {
|
|
|
37
37
|
connect(): void {
|
|
38
38
|
if (this.closed || typeof WebSocket === 'undefined') return
|
|
39
39
|
this.clearReconnectTimer()
|
|
40
|
+
this.disposeSocket()
|
|
40
41
|
try {
|
|
41
42
|
const ws = new WebSocket(this.url)
|
|
42
43
|
this.ws = ws
|
|
@@ -48,7 +49,7 @@ export class FormRuntimeWsClient {
|
|
|
48
49
|
this.handleMessage(event.data)
|
|
49
50
|
}
|
|
50
51
|
ws.onclose = () => {
|
|
51
|
-
this.ws = null
|
|
52
|
+
if (this.ws === ws) this.ws = null
|
|
52
53
|
this.scheduleReconnect()
|
|
53
54
|
}
|
|
54
55
|
ws.onerror = () => {
|
|
@@ -88,18 +89,22 @@ export class FormRuntimeWsClient {
|
|
|
88
89
|
this.closed = true
|
|
89
90
|
this.clearReconnectTimer()
|
|
90
91
|
this.sendQueue = []
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
92
|
+
this.disposeSocket()
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** 关闭当前 socket 且不触发重连(清掉 onclose),建新连或主动 close 时用。 */
|
|
96
|
+
private disposeSocket(): void {
|
|
97
|
+
if (!this.ws) return
|
|
98
|
+
this.ws.onopen = null
|
|
99
|
+
this.ws.onmessage = null
|
|
100
|
+
this.ws.onclose = null
|
|
101
|
+
this.ws.onerror = null
|
|
102
|
+
try {
|
|
103
|
+
this.ws.close()
|
|
104
|
+
} catch {
|
|
105
|
+
// ignore
|
|
102
106
|
}
|
|
107
|
+
this.ws = null
|
|
103
108
|
}
|
|
104
109
|
|
|
105
110
|
private enqueue(serialized: string): void {
|