@skopon-cool/form-sdk 0.3.1 → 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.
@@ -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;IA0Bf,IAAI,CACF,IAAI,EAAE,uBAAuB,EAC7B,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC5B,IAAI;IAqBP,KAAK,IAAI,IAAI;IAkBb,OAAO,CAAC,OAAO;IAOf,OAAO,CAAC,UAAU;IASlB,OAAO,CAAC,iBAAiB;IAczB,OAAO,CAAC,mBAAmB;IAO3B,OAAO,CAAC,aAAa;CAgBtB"}
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
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.3.1",
6
+ "version": "0.3.2",
7
7
  "description": "Skopon form rendering SDK (A2UI + form_definition) with submit helpers",
8
8
  "type": "module",
9
9
  "sideEffects": [
@@ -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
- const shouldConnect = Boolean(
401
- realtimeWsUrl && resolvedAskUserMessageId && surfaceReady,
402
- )
403
-
404
- useEffect(() => {
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
- client.connect()
417
- setWsClient(client)
421
+ }, [realtimeWsUrl, resolvedAskUserMessageId])
422
+
423
+ useEffect(() => {
424
+ if (!wsClient) return
418
425
  return () => {
419
- client.close()
420
- setWsClient(null)
426
+ wsClient.close()
421
427
  }
422
- }, [shouldConnect, realtimeWsUrl, resolvedAskUserMessageId])
428
+ }, [wsClient])
429
+
430
+ useEffect(() => {
431
+ if (!wsClient || !surfaceReady) return
432
+ wsClient.connect()
433
+ }, [wsClient, surfaceReady])
423
434
 
424
435
  return (
425
436
  <SkoponFormProvider>
@@ -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
- if (this.ws) {
92
- this.ws.onopen = null
93
- this.ws.onmessage = null
94
- this.ws.onclose = null
95
- this.ws.onerror = null
96
- try {
97
- this.ws.close()
98
- } catch {
99
- // ignore
100
- }
101
- this.ws = null
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 {