@uniai-fe/uds-templates 0.9.1 → 0.10.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniai-fe/uds-templates",
3
- "version": "0.9.1",
3
+ "version": "0.10.1",
4
4
  "description": "UNIAI Design System; UI Templates Package",
5
5
  "type": "module",
6
6
  "private": false,
@@ -37,14 +37,14 @@
37
37
  "peerDependencies": {
38
38
  "@tanstack/react-query": ">=5 <6",
39
39
  "@uniai-fe/uds-foundation": "^0.4.8 || ^0.5.0",
40
- "@uniai-fe/uds-primitives": "^0.8.0 || ^0.9.0",
40
+ "@uniai-fe/uds-primitives": "^0.8.0 || ^0.9.0 || ^0.10.0",
41
41
  "@uniai-fe/util-api": "^0.1.15 || ^0.2.0",
42
42
  "@uniai-fe/util-functions": "^0.3.0 || ^0.4.0",
43
43
  "@uniai-fe/util-jotai": "^0.1.9 || ^0.2.0 || ^0.3.0",
44
44
  "@uniai-fe/util-next": "^0.3.0 || ^0.4.0 || ^0.5.0",
45
45
  "@uniai-fe/util-rtc": "^0.1.4 || ^0.2.0",
46
46
  "jotai": ">=2 <3",
47
- "next": ">=16 <17",
47
+ "next": ">=15.5.20 <17",
48
48
  "react": ">=19 <20",
49
49
  "react-dom": ">=19 <20",
50
50
  "react-hook-form": ">=7 <8",
@@ -67,17 +67,17 @@
67
67
  "react-hook-form": "^7.80.0",
68
68
  "sass": "^1.101.0",
69
69
  "typescript": "6.0.3",
70
- "@uniai-fe/uds-primitives": "0.9.3",
71
- "@uniai-fe/util-functions": "0.4.1",
72
- "@uniai-fe/util-api": "0.2.1",
73
- "@uniai-fe/uds-foundation": "0.5.0",
74
- "@uniai-fe/react-hooks": "0.2.0",
70
+ "@uniai-fe/eslint-config": "0.4.0",
75
71
  "@uniai-fe/next-devkit": "0.4.0",
76
- "@uniai-fe/eslint-config": "0.3.1",
72
+ "@uniai-fe/react-hooks": "0.3.0",
73
+ "@uniai-fe/tsconfig": "0.2.0",
74
+ "@uniai-fe/uds-foundation": "0.5.0",
75
+ "@uniai-fe/util-api": "0.2.1",
76
+ "@uniai-fe/util-functions": "0.4.1",
77
77
  "@uniai-fe/util-jotai": "0.3.0",
78
78
  "@uniai-fe/util-next": "0.5.0",
79
- "@uniai-fe/tsconfig": "0.2.0",
80
- "@uniai-fe/util-rtc": "0.2.0"
79
+ "@uniai-fe/util-rtc": "0.2.0",
80
+ "@uniai-fe/uds-primitives": "0.10.0"
81
81
  },
82
82
  "scripts": {
83
83
  "check:pre-commit": "pnpm --dir ../../.. run check:pre-commit",
@@ -3,7 +3,7 @@
3
3
  import clsx from "clsx";
4
4
  import { useEffect } from "react";
5
5
  import type { SubmitEvent } from "react";
6
- import { useFormContext } from "react-hook-form";
6
+ import { useFormContext, useWatch } from "react-hook-form";
7
7
  import { AuthContainer } from "../../container";
8
8
  import { Button, type ButtonProps } from "@uniai-fe/uds-primitives";
9
9
  import type { AuthCodeInputProps } from "@uniai-fe/uds-primitives";
@@ -56,7 +56,11 @@ export function AuthFindAccountCodeStep({
56
56
  form.register(codeFieldName);
57
57
  }, [form, codeFieldName]);
58
58
 
59
- const codeValue = form.watch(codeFieldName) ?? "";
59
+ const codeValue =
60
+ useWatch({
61
+ control: form.control,
62
+ name: codeFieldName,
63
+ }) ?? "";
60
64
 
61
65
  const handleCodeValueChange = (nextValue: string) => {
62
66
  const digits = sanitizeDigits(nextValue);
@@ -88,13 +92,11 @@ export function AuthFindAccountCodeStep({
88
92
  ) : undefined);
89
93
 
90
94
  const { onSubmit: formAttrOnSubmit, ...restFormAttr } = formAttr ?? {};
91
- const handleFormSubmit = (event: React.FormEvent<HTMLFormElement>) => {
92
- const nativeSubmitEvent =
93
- event.nativeEvent as unknown as SubmitEvent<HTMLFormElement>;
94
- formAttrOnSubmit?.(nativeSubmitEvent);
95
+ const handleFormSubmit = (event: SubmitEvent<HTMLFormElement>) => {
96
+ formAttrOnSubmit?.(event);
95
97
  if (event.defaultPrevented) return;
96
98
  event.preventDefault();
97
- form.handleSubmit(onSubmit)(nativeSubmitEvent);
99
+ form.handleSubmit(onSubmit)(event);
98
100
  };
99
101
 
100
102
  const emailTemplateProps = {
@@ -62,16 +62,14 @@ export function AuthFindAccountInfoStep({
62
62
  <FindAccountHeader navigation={navigation} headline={headline} />
63
63
  ) : undefined);
64
64
 
65
- const handleFormSubmit = (event: React.FormEvent<HTMLFormElement>) => {
66
- const nativeEvent =
67
- event.nativeEvent as unknown as SubmitEvent<HTMLFormElement>;
68
- formAttr?.onSubmit?.(nativeEvent);
69
- if (nativeEvent.defaultPrevented) {
65
+ const handleFormSubmit = (event: SubmitEvent<HTMLFormElement>) => {
66
+ formAttr?.onSubmit?.(event);
67
+ if (event.defaultPrevented) {
70
68
  event.preventDefault();
71
69
  return;
72
70
  }
73
71
  event.preventDefault();
74
- form.handleSubmit(onSubmit)(nativeEvent);
72
+ form.handleSubmit(onSubmit)(event);
75
73
  };
76
74
 
77
75
  return (
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import clsx from "clsx";
4
- import { FormProvider, useForm } from "react-hook-form";
4
+ import { FormProvider, useForm, useWatch } from "react-hook-form";
5
5
  import { AuthContainer } from "../../common/container";
6
6
  import { AuthStageHeader } from "../../common/container/header";
7
7
  import { Button } from "@uniai-fe/uds-primitives";
@@ -43,7 +43,14 @@ export default function FindPasswordStepReset({
43
43
  defaultValues,
44
44
  });
45
45
 
46
- const watchedValues = form.watch();
46
+ const [passwordValue, confirmValue] = useWatch({
47
+ control: form.control,
48
+ name: [passwordFieldName, confirmFieldName],
49
+ });
50
+ const watchedValues: FindAccountPasswordValues = {
51
+ [passwordFieldName]: passwordValue ?? "",
52
+ [confirmFieldName]: confirmValue ?? "",
53
+ };
47
54
  const resolvedFilled = isSubmittable
48
55
  ? isSubmittable(watchedValues)
49
56
  : Object.values(watchedValues).every(value =>
@@ -3,7 +3,7 @@
3
3
  import { useMemo } from "react";
4
4
  import { useController, useForm } from "react-hook-form";
5
5
  import { Button, CheckboxField } from "@uniai-fe/uds-primitives";
6
- import type { FormEventHandler, SubmitEvent } from "react";
6
+ import type { SubmitEventHandler } from "react";
7
7
  import type { AuthLoginFieldOptions, AuthLoginFormValues } from "../types";
8
8
  import { useAuthLoginForm } from "../hooks";
9
9
  import AuthLoginFormFieldId from "./UserId";
@@ -68,19 +68,23 @@ export default function AuthLoginFormField({
68
68
  onLogin,
69
69
  });
70
70
 
71
- const { field: keepLoginController } = useController({
71
+ const {
72
+ field: {
73
+ onBlur: onKeepLoginBlur,
74
+ onChange: onKeepLoginChange,
75
+ ref: keepLoginRef,
76
+ value: keepLoginValue,
77
+ },
78
+ } = useController({
72
79
  control: form.control,
73
80
  name: keepLoginFieldName,
74
81
  });
75
82
 
76
- const handleFormSubmit: FormEventHandler<HTMLFormElement> = event => {
77
- const nativeEvent =
78
- event.nativeEvent as unknown as SubmitEvent<HTMLFormElement>;
79
-
83
+ const handleFormSubmit: SubmitEventHandler<HTMLFormElement> = event => {
80
84
  // 소비 앱이 formAttr.onSubmit에서 analytics, validation bridge, custom preventDefault 등을
81
85
  // 처리할 수 있으므로 템플릿 guard보다 먼저 실행한다.
82
- formAttr?.onSubmit?.(nativeEvent);
83
- if (event.defaultPrevented || nativeEvent.defaultPrevented) {
86
+ formAttr?.onSubmit?.(event);
87
+ if (event.defaultPrevented) {
84
88
  event.preventDefault();
85
89
  return;
86
90
  }
@@ -190,18 +194,18 @@ export default function AuthLoginFormField({
190
194
  />
191
195
  <CheckboxField
192
196
  {...keepLoginCheckboxProps}
193
- ref={keepLoginController.ref}
197
+ ref={keepLoginRef}
194
198
  name={keepLoginFieldName}
195
- checked={keepLoginController.value === true}
199
+ checked={keepLoginValue === true}
196
200
  disabled={isKeepLoginDisabled}
197
201
  label={keepLoginLabel}
198
202
  helperText={keepLoginHelper}
199
203
  fieldClassName={["auth-login-keep-login", keepLoginFieldClassName]
200
204
  .filter(Boolean)
201
205
  .join(" ")}
202
- onBlur={keepLoginController.onBlur}
206
+ onBlur={onKeepLoginBlur}
203
207
  onCheckedChange={checked => {
204
- keepLoginController.onChange(checked === true);
208
+ onKeepLoginChange(checked === true);
205
209
  }}
206
210
  />
207
211
  <Button.Default
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { useEffect, useState } from "react";
3
+ import { useCallback, useEffect, useSyncExternalStore } from "react";
4
4
  import type {
5
5
  CctvRtcStreamOptions,
6
6
  CctvRtcStreamScheduleStatus,
@@ -244,31 +244,26 @@ export function useCctvRtcStreamScheduleStatus({
244
244
  */
245
245
  scheduler: CctvRtcStreamScheduler;
246
246
  }): CctvRtcStreamScheduleStatus {
247
- const [status, setStatus] = useState<CctvRtcStreamScheduleStatus>("idle");
247
+ const getSnapshot = useCallback(
248
+ (): CctvRtcStreamScheduleStatus =>
249
+ enabled && identityKey ? scheduler.getStatus(identityKey) : "idle",
250
+ [enabled, identityKey, scheduler],
251
+ );
252
+ const status = useSyncExternalStore(
253
+ scheduler.subscribe,
254
+ getSnapshot,
255
+ getSnapshot,
256
+ );
248
257
 
249
258
  useEffect(() => {
250
259
  // 필수 identity가 없거나 offline이면 scheduler에 등록하지 않고 idle로 되돌린다.
251
260
  if (!enabled || !identityKey) {
252
- setStatus("idle");
253
261
  return;
254
262
  }
255
-
256
- // scheduler mutable state를 React state로 복사해 render가 status 변화를 감지하게 한다.
257
- const syncStatus = () => {
258
- setStatus(scheduler.getStatus(identityKey));
259
- };
260
-
261
- // subscribe 이후 request해야 request 직후 notify도 놓치지 않는다.
262
- const unsubscribe = scheduler.subscribe(syncStatus);
263
263
  const release = scheduler.request(identityKey);
264
264
 
265
- // request 직후 현재 상태를 즉시 반영해 queued/granted 표시 지연을 줄인다.
266
- syncStatus();
267
-
268
265
  return () => {
269
- // cleanup 순서는 scheduler reference release 후 listener 제거로 둬 release notify를 받을 수 있게 한다.
270
266
  release();
271
- unsubscribe();
272
267
  };
273
268
  }, [enabled, identityKey, scheduler]);
274
269
 
@@ -1,7 +1,14 @@
1
1
  "use client";
2
2
 
3
3
  // React 훅들을 활용해 WebRTC 스트림 라이프사이클을 제어한다.
4
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
+ import {
5
+ useCallback,
6
+ useEffect,
7
+ useId,
8
+ useMemo,
9
+ useRef,
10
+ useState,
11
+ } from "react";
5
12
  import { useSetAtom } from "jotai";
6
13
 
7
14
  // 토큰 발급 쿼리와 API URL 컨텍스트 훅, 타입, react-hook-form 유틸.
@@ -193,11 +200,8 @@ export function useCctvRtcStream({
193
200
  // live registry는 여러 hook instance가 같은 cam의 live 상태를 공유하는 Jotai atom이다.
194
201
  const setLiveRegistry = useSetAtom(cctvRtcLiveRegistryAtom);
195
202
 
196
- // 같은 cam을 여러 위치에서 렌더할 수 있어 hook instance별 key를 한 번만 만든다.
197
- const instanceKeyRef = useRef<string | null>(null);
198
- if (!instanceKeyRef.current) {
199
- instanceKeyRef.current = Math.random().toString(36).slice(2);
200
- }
203
+ // 같은 cam을 여러 위치에서 렌더할 수 있어 hook instance별 stable key를 사용한다.
204
+ const instanceKey = useId();
201
205
 
202
206
  // WebRTC MediaStream을 연결할 video 요소 ref.
203
207
  const videoRef = useRef<HTMLVideoElement | null>(null);
@@ -215,17 +219,17 @@ export function useCctvRtcStream({
215
219
  const staleTokenRefreshKeyRef = useRef<string | null>(null);
216
220
 
217
221
  // RTCPeerConnectionState를 관찰해 UI에 노출하기 위한 상태값.
218
- const [connectionState, setConnectionState] =
222
+ const [connectionStateValue, setConnectionState] =
219
223
  useState<RTCPeerConnectionState>("new");
220
224
 
221
225
  // 스트림 시도 중 발생한 오류 메시지를 저장한다.
222
- const [streamError, setStreamError] = useState<string | null>(null);
226
+ const [streamErrorValue, setStreamError] = useState<string | null>(null);
223
227
 
224
228
  // 현재 스트림 연결 절차가 진행 중인지 여부.
225
- const [isStreaming, setStreaming] = useState(false);
226
- const [hasConnected, setHasConnected] = useState(false);
227
- const [isPostConnectedReconnectReady, setPostConnectedReconnectReady] =
228
- useState(false);
229
+ const [isStreamingValue, setStreaming] = useState(false);
230
+ const [streamStateIdentityKey, setStreamStateIdentityKey] = useState("");
231
+ const [connectedIdentityKey, setConnectedIdentityKey] = useState("");
232
+ const [reconnectReadyKey, setReconnectReadyKey] = useState("");
229
233
 
230
234
  // react-hook-form 컨텍스트에서 token username과 WHEP username override를 추적한다.
231
235
  const { control } = useFormContext();
@@ -235,7 +239,7 @@ export function useCctvRtcStream({
235
239
  whepUsername === undefined ? tokenUsername : whepUsername;
236
240
 
237
241
  // 카메라의 RTC 엔드포인트와 사용자명을 조합해 stable WHEP identity endpoint를 계산한다.
238
- const streamIdentityEndpoint = useMemo(() => {
242
+ const streamIdentityEndpoint = (() => {
239
243
  // cam_rtc가 없으면 WHEP endpoint를 만들 수 없어 이후 identity/token gate도 닫힌다.
240
244
  if (!cam?.cam_rtc) return undefined;
241
245
 
@@ -248,9 +252,9 @@ export function useCctvRtcStream({
248
252
  }
249
253
 
250
254
  return nextEndpoint.toString();
251
- }, [cam?.cam_rtc, endpointUsername]);
255
+ })();
252
256
 
253
- const streamIdentityKey = useMemo(() => {
257
+ const streamIdentityKey = (() => {
254
258
  // identity 필수값이 모두 있어야 scheduler와 registry identity cleanup이 가능하다.
255
259
  if (
256
260
  !tokenUsername ||
@@ -267,7 +271,13 @@ export function useCctvRtcStream({
267
271
  cam.cam_id,
268
272
  streamIdentityEndpoint,
269
273
  ].join("|");
270
- }, [cam?.cam_id, cam?.company_id, streamIdentityEndpoint, tokenUsername]);
274
+ })();
275
+ const hasCurrentStreamState = streamStateIdentityKey === streamIdentityKey;
276
+ const connectionState = hasCurrentStreamState ? connectionStateValue : "new";
277
+ const streamError = hasCurrentStreamState ? streamErrorValue : null;
278
+ const isStreaming = hasCurrentStreamState ? isStreamingValue : false;
279
+ const hasConnected =
280
+ Boolean(streamIdentityKey) && connectedIdentityKey === streamIdentityKey;
271
281
 
272
282
  // scheduler grant 전에는 token query enabled가 false라 초기 token burst가 발생하지 않는다.
273
283
  const streamScheduleStatus = useCctvRtcStreamScheduleStatus({
@@ -292,7 +302,7 @@ export function useCctvRtcStream({
292
302
  const isTokenError = tokenQuery.isError || isTokenEndpointMissing;
293
303
 
294
304
  // token-specific ref는 실제 WHEP 요청 URL에만 붙이고 stream identity/key에서는 제외한다.
295
- const requestEndpoint = useMemo(() => {
305
+ const requestEndpoint = (() => {
296
306
  if (!streamIdentityEndpoint || !tokenQuery.data?.session_ref)
297
307
  return undefined;
298
308
 
@@ -300,9 +310,9 @@ export function useCctvRtcStream({
300
310
  nextEndpoint.searchParams.set("session_ref", tokenQuery.data.session_ref);
301
311
 
302
312
  return nextEndpoint.toString();
303
- }, [streamIdentityEndpoint, tokenQuery.data?.session_ref]);
313
+ })();
304
314
 
305
- const streamKeyCandidate = useMemo(() => {
315
+ const streamKeyCandidate = (() => {
306
316
  // streamKey는 online 카메라와 endpoint/token이 모두 준비된 뒤에만 만들 수 있다.
307
317
  if (
308
318
  !cam?.cam_id ||
@@ -320,20 +330,7 @@ export function useCctvRtcStream({
320
330
  streamIdentityEndpoint,
321
331
  tokenQuery.dataUpdatedAt,
322
332
  ].join("|");
323
- }, [
324
- cam?.cam_id,
325
- cam?.cam_online,
326
- cam?.company_id,
327
- streamIdentityEndpoint,
328
- tokenQuery.data?.token,
329
- tokenQuery.dataUpdatedAt,
330
- tokenUsername,
331
- ]);
332
-
333
- useEffect(() => {
334
- // identity가 바뀌면 이전 연결 성공 이력을 버려 reconnect gate를 초기 연결 상태로 되돌린다.
335
- setHasConnected(false);
336
- }, [streamIdentityKey]);
333
+ })();
337
334
 
338
335
  const reconnectReason = useMemo(
339
336
  () =>
@@ -354,21 +351,16 @@ export function useCctvRtcStream({
354
351
  !isTokenLoading &&
355
352
  !isStreaming;
356
353
 
357
- if (!canStartReconnectTimer) {
358
- // timer 조건이 사라지면 재연결 가능 표시도 즉시 닫는다.
359
- setPostConnectedReconnectReady(false);
360
- return;
361
- }
354
+ if (!canStartReconnectTimer) return;
362
355
 
363
- // 장애 reason이 들어올 때마다 grace/stagger 대기 상태부터 다시 시작한다.
364
- setPostConnectedReconnectReady(false);
356
+ const nextReconnectReadyKey = `${streamIdentityKey}|${reconnectReason}`;
365
357
  const reconnectDelayMs = getPostConnectedReconnectDelayMs(
366
- `${streamIdentityKey}|${reconnectReason}`,
358
+ nextReconnectReadyKey,
367
359
  );
368
360
 
369
361
  // cam identity 기반 delay 이후에만 UI/auto reconnect가 재시도할 수 있게 한다.
370
362
  const timeout = setTimeout(() => {
371
- setPostConnectedReconnectReady(true);
363
+ setReconnectReadyKey(nextReconnectReadyKey);
372
364
  }, reconnectDelayMs);
373
365
 
374
366
  return () => {
@@ -432,13 +424,10 @@ export function useCctvRtcStream({
432
424
  );
433
425
  }, [streamKeyCandidate, streamRegistry]);
434
426
 
435
- const canUseTokenForStream = useMemo(() => {
427
+ const canUseTokenForStream = (() => {
436
428
  // token 또는 streamKey 후보가 없으면 WHEP start 조건이 닫힌다.
437
429
  if (!streamKeyCandidate || !tokenQuery.data?.token) return false;
438
430
 
439
- // 현재 hook이 이미 붙어 있는 streamKey면 재판정 없이 유지한다.
440
- if (activeStreamKeyRef.current === streamKeyCandidate) return true;
441
-
442
431
  // registry에 재사용 가능한 stream이 있으면 stale token이어도 새 WHEP POST 없이 attach만 한다.
443
432
  if (hasReusableRegistryStream) return true;
444
433
 
@@ -447,12 +436,7 @@ export function useCctvRtcStream({
447
436
  dataUpdatedAt: tokenQuery.dataUpdatedAt,
448
437
  token: tokenQuery.data.token,
449
438
  });
450
- }, [
451
- hasReusableRegistryStream,
452
- streamKeyCandidate,
453
- tokenQuery.data?.token,
454
- tokenQuery.dataUpdatedAt,
455
- ]);
439
+ })();
456
440
 
457
441
  // streamKey가 빈 문자열이면 아래 stream effect가 WHEP start를 하지 않는다.
458
442
  const streamKey = canUseTokenForStream ? streamKeyCandidate : "";
@@ -506,10 +490,6 @@ export function useCctvRtcStream({
506
490
  // DOM video에는 registry cleanup과 별개로 srcObject 잔상을 즉시 비운다.
507
491
  if (currentVideo) currentVideo.srcObject = null;
508
492
 
509
- // UI state도 초기 연결 전 상태로 되돌린다.
510
- setConnectionState("new");
511
- setStreamError(null);
512
- setStreaming(false);
513
493
  return;
514
494
  }
515
495
 
@@ -560,13 +540,14 @@ export function useCctvRtcStream({
560
540
 
561
541
  // registry snapshot 변경을 hook local state와 DOM video에 반영한다.
562
542
  const unsubscribe = streamRegistry.subscribe(streamKey, snapshot => {
543
+ setStreamStateIdentityKey(streamIdentityKey);
563
544
  setConnectionState(snapshot.connectionState);
564
545
  setStreamError(snapshot.streamError);
565
546
  setStreaming(snapshot.isStreaming);
566
547
 
567
548
  // 한 번이라도 connected가 되면 이후 장애를 post-connected recovery로 취급한다.
568
549
  if (snapshot.connectionState === "connected") {
569
- setHasConnected(true);
550
+ setConnectedIdentityKey(streamIdentityKey);
570
551
  }
571
552
 
572
553
  // onTrack으로 MediaStream이 들어온 뒤에만 video.srcObject를 교체한다.
@@ -582,11 +563,6 @@ export function useCctvRtcStream({
582
563
 
583
564
  // subscribe 직후 현재 snapshot을 즉시 반영해 기존 registry stream reattach 지연을 줄인다.
584
565
  const snapshot = streamRegistry.getSnapshot(streamKey);
585
- setConnectionState(snapshot.connectionState);
586
- setStreamError(snapshot.streamError);
587
- setStreaming(snapshot.isStreaming);
588
- if (snapshot.connectionState === "connected") setHasConnected(true);
589
-
590
566
  if (snapshot.stream) {
591
567
  // 이미 registry에 MediaStream이 있으면 첫 render cycle에서도 즉시 video에 붙인다.
592
568
  if (currentVideo.srcObject !== snapshot.stream) {
@@ -634,11 +610,11 @@ export function useCctvRtcStream({
634
610
  if (!reconnectReason) return false;
635
611
 
636
612
  // grace/stagger timer가 끝난 뒤에만 public reconnect 가능 상태를 연다.
637
- return isPostConnectedReconnectReady;
613
+ return reconnectReadyKey === `${streamIdentityKey}|${reconnectReason}`;
638
614
  }, [
639
615
  cam?.cam_online,
640
616
  hasConnected,
641
- isPostConnectedReconnectReady,
617
+ reconnectReadyKey,
642
618
  isStreaming,
643
619
  isTokenLoading,
644
620
  reconnectReason,
@@ -759,7 +735,6 @@ export function useCctvRtcStream({
759
735
  if (!camId) return;
760
736
 
761
737
  // 같은 cam을 여러 위치에서 렌더할 수 있어 instance 단위 live state를 보관한다.
762
- const instanceKey = instanceKeyRef.current as string;
763
738
  setLiveRegistry(prev => {
764
739
  // jotai atom update는 기존 cam map을 얕은 복사해 React 변경 감지를 보장한다.
765
740
  const next = { ...prev };
@@ -768,14 +743,13 @@ export function useCctvRtcStream({
768
743
  next[camId] = perCam;
769
744
  return next;
770
745
  });
771
- }, [cam?.cam_id, liveState, setLiveRegistry]);
746
+ }, [cam?.cam_id, instanceKey, liveState, setLiveRegistry]);
772
747
 
773
748
  useEffect(() => {
774
749
  // unmount cleanup도 cam_id 기준이라 cam이 없으면 등록할 cleanup이 없다.
775
750
  const camId = cam?.cam_id;
776
751
  if (!camId) return;
777
752
 
778
- const instanceKey = instanceKeyRef.current as string;
779
753
  return () => {
780
754
  // 현재 hook instance만 제거하고 같은 cam의 다른 렌더 위치는 유지한다.
781
755
  setLiveRegistry(prev => {
@@ -792,7 +766,7 @@ export function useCctvRtcStream({
792
766
  return next;
793
767
  });
794
768
  };
795
- }, [cam?.cam_id, setLiveRegistry]);
769
+ }, [cam?.cam_id, instanceKey, setLiveRegistry]);
796
770
 
797
771
  // 호출자에게 video ref와 상태값, 토큰 쿼리 상태를 전달한다.
798
772
  return {
@@ -1,11 +1,11 @@
1
- import type { MutableRefObject, ReactNode } from "react";
1
+ import type { ReactNode, RefObject } from "react";
2
2
 
3
3
  /**
4
4
  * CCTV Pagination Carousel Context 값 모양.
5
5
  */
6
6
  export interface CctvPaginationCarouselContextValue {
7
- viewportRef: MutableRefObject<HTMLDivElement | null>;
8
- trackRef: MutableRefObject<HTMLUListElement | null>;
7
+ viewportRef: RefObject<HTMLDivElement | null>;
8
+ trackRef: RefObject<HTMLUListElement | null>;
9
9
  onPrev: () => void;
10
10
  onNext: () => void;
11
11
  moveTo: (index: number) => void;
@@ -4,7 +4,6 @@ import clsx from "clsx";
4
4
  import { MobileFrameContainer } from "./Container";
5
5
  import { MobileFrameNavigation } from "../navigation";
6
6
  import type { MobileFrameProps } from "../../types";
7
- import React, { useCallback } from "react";
8
7
 
9
8
  /**
10
9
  * 모바일 전용 Page Frame 래퍼; 공통 컨테이너 위에 모바일 클래스/스타일을 덧입힌다.
@@ -22,12 +21,9 @@ export function MobileFrame({
22
21
  children,
23
22
  ...rest
24
23
  }: MobileFrameProps) {
25
- const Footer = useCallback(
26
- (): React.ReactNode =>
27
- footer ??
28
- (navigationProps ? <MobileFrameNavigation {...navigationProps} /> : null),
29
- [footer, navigationProps],
30
- );
24
+ const resolvedFooter =
25
+ footer ??
26
+ (navigationProps ? <MobileFrameNavigation {...navigationProps} /> : null);
31
27
 
32
28
  return (
33
29
  <MobileFrameContainer
@@ -36,7 +32,7 @@ export function MobileFrame({
36
32
  header={header}
37
33
  headerClassName={clsx("page-frame-mobile-header", headerClassName)}
38
34
  bodyClassName={clsx("page-frame-mobile-body", bodyClassName)}
39
- footer={<Footer />}
35
+ footer={resolvedFooter}
40
36
  footerClassName={clsx("page-frame-mobile-footer", footerClassName)}
41
37
  >
42
38
  {children}
@@ -5,7 +5,7 @@ import { Chip, Form, Input } from "@uniai-fe/uds-primitives";
5
5
  import { useEffect } from "react";
6
6
  import type { ServiceInquiryFormProps } from "../types";
7
7
  import type { ServiceInquiryFormValues, ServiceInquiryType } from "../types";
8
- import { useFormContext } from "react-hook-form";
8
+ import { useFormContext, useWatch } from "react-hook-form";
9
9
 
10
10
  const DEFAULT_INQUIRY_TYPE_OPTIONS: ServiceInquiryType[] = [
11
11
  "접속이 안 돼요",
@@ -38,7 +38,10 @@ const ServiceInquiryForm = ({
38
38
  onSubmit,
39
39
  }: ServiceInquiryFormProps) => {
40
40
  const form = useFormContext<ServiceInquiryFormValues>();
41
- const selectedInquiryType = form.watch("inquiry_type");
41
+ const selectedInquiryType = useWatch({
42
+ control: form.control,
43
+ name: "inquiry_type",
44
+ });
42
45
  const inquiryTypeOptions =
43
46
  inquiryTypeField?.options ?? DEFAULT_INQUIRY_TYPE_OPTIONS;
44
47
  const farmNameState = form.getFieldState("farm_name", form.formState);