@uniai-fe/uds-templates 0.8.3 → 0.10.0

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/styles.css CHANGED
@@ -318,7 +318,7 @@
318
318
 
319
319
  .page-frame-nav-category-label {
320
320
  font-size: var(--uds-page-nav-text-size);
321
- line-height: 1.7rem;
321
+ line-height: var(--uds-page-nav-text-line-height);
322
322
  color: inherit;
323
323
  }
324
324
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniai-fe/uds-templates",
3
- "version": "0.8.3",
3
+ "version": "0.10.0",
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
- "@uniai-fe/util-jotai": "^0.1.9 || ^0.2.0",
44
- "@uniai-fe/util-next": "^0.3.0 || ^0.4.0",
43
+ "@uniai-fe/util-jotai": "^0.1.9 || ^0.2.0 || ^0.3.0",
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": ">=15 <16",
47
+ "next": ">=16 <17",
48
48
  "react": ">=19 <20",
49
49
  "react-dom": ">=19 <20",
50
50
  "react-hook-form": ">=7 <8",
@@ -62,21 +62,21 @@
62
62
  "@types/react-dom": "^19.2.3",
63
63
  "eslint": "^9.39.2",
64
64
  "jotai": "^2.20.1",
65
- "next": "^15.5.18",
65
+ "next": "16.2.10",
66
66
  "prettier": "^3.8.4",
67
67
  "react-hook-form": "^7.80.0",
68
68
  "sass": "^1.101.0",
69
69
  "typescript": "6.0.3",
70
- "@uniai-fe/eslint-config": "0.2.0",
71
- "@uniai-fe/next-devkit": "0.3.0",
72
- "@uniai-fe/uds-foundation": "0.5.0",
70
+ "@uniai-fe/eslint-config": "0.4.0",
71
+ "@uniai-fe/next-devkit": "0.4.0",
73
72
  "@uniai-fe/tsconfig": "0.2.0",
74
- "@uniai-fe/react-hooks": "0.2.0",
75
- "@uniai-fe/uds-primitives": "0.9.3",
76
- "@uniai-fe/util-api": "0.2.1",
73
+ "@uniai-fe/react-hooks": "0.3.0",
74
+ "@uniai-fe/uds-primitives": "0.10.0",
77
75
  "@uniai-fe/util-functions": "0.4.1",
78
- "@uniai-fe/util-jotai": "0.2.0",
79
- "@uniai-fe/util-next": "0.4.1",
76
+ "@uniai-fe/uds-foundation": "0.5.0",
77
+ "@uniai-fe/util-jotai": "0.3.0",
78
+ "@uniai-fe/util-api": "0.2.1",
79
+ "@uniai-fe/util-next": "0.5.0",
80
80
  "@uniai-fe/util-rtc": "0.2.0"
81
81
  },
82
82
  "scripts": {
@@ -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 엔드포인트와 사용자명을 조합해 실제 WHEP endpoint를 계산한다.
238
- const endpoint = useMemo(() => {
242
+ const endpoint = (() => {
239
243
  // cam_rtc가 없으면 WHEP endpoint를 만들 수 없어 이후 identity/token gate도 닫힌다.
240
244
  if (!cam?.cam_rtc) return undefined;
241
245
 
@@ -246,16 +250,22 @@ export function useCctvRtcStream({
246
250
 
247
251
  // cam_rtc 뒤의 slash 중복을 제거하고 WHEP path를 붙인다.
248
252
  return `${cam.cam_rtc.replace(/\/$/, "")}/whep${query}`;
249
- }, [cam?.cam_rtc, endpointUsername]);
253
+ })();
250
254
 
251
- const streamIdentityKey = useMemo(() => {
255
+ const streamIdentityKey = (() => {
252
256
  // identity 필수값이 모두 있어야 scheduler와 registry identity cleanup이 가능하다.
253
257
  if (!tokenUsername || !cam?.company_id || !cam.cam_id || !endpoint)
254
258
  return "";
255
259
 
256
260
  // token 갱신 시각은 제외해 같은 카메라 stream family를 하나의 identity로 묶는다.
257
261
  return [tokenUsername, cam.company_id, cam.cam_id, endpoint].join("|");
258
- }, [cam?.cam_id, cam?.company_id, endpoint, tokenUsername]);
262
+ })();
263
+ const hasCurrentStreamState = streamStateIdentityKey === streamIdentityKey;
264
+ const connectionState = hasCurrentStreamState ? connectionStateValue : "new";
265
+ const streamError = hasCurrentStreamState ? streamErrorValue : null;
266
+ const isStreaming = hasCurrentStreamState ? isStreamingValue : false;
267
+ const hasConnected =
268
+ Boolean(streamIdentityKey) && connectedIdentityKey === streamIdentityKey;
259
269
 
260
270
  // scheduler grant 전에는 token query enabled가 false라 초기 token burst가 발생하지 않는다.
261
271
  const streamScheduleStatus = useCctvRtcStreamScheduleStatus({
@@ -279,7 +289,7 @@ export function useCctvRtcStream({
279
289
  const isTokenEndpointMissing = isStreamStartGranted && !tokenEndpointUrl;
280
290
  const isTokenError = tokenQuery.isError || isTokenEndpointMissing;
281
291
 
282
- const streamKeyCandidate = useMemo(() => {
292
+ const streamKeyCandidate = (() => {
283
293
  // streamKey는 online 카메라와 endpoint/token이 모두 준비된 뒤에만 만들 수 있다.
284
294
  if (!cam?.cam_id || !cam.cam_online || !endpoint || !tokenQuery.data?.token)
285
295
  return "";
@@ -292,20 +302,7 @@ export function useCctvRtcStream({
292
302
  endpoint,
293
303
  tokenQuery.dataUpdatedAt,
294
304
  ].join("|");
295
- }, [
296
- cam?.cam_id,
297
- cam?.cam_online,
298
- cam?.company_id,
299
- endpoint,
300
- tokenQuery.data?.token,
301
- tokenQuery.dataUpdatedAt,
302
- tokenUsername,
303
- ]);
304
-
305
- useEffect(() => {
306
- // identity가 바뀌면 이전 연결 성공 이력을 버려 reconnect gate를 초기 연결 상태로 되돌린다.
307
- setHasConnected(false);
308
- }, [streamIdentityKey]);
305
+ })();
309
306
 
310
307
  const reconnectReason = useMemo(
311
308
  () =>
@@ -326,21 +323,16 @@ export function useCctvRtcStream({
326
323
  !isTokenLoading &&
327
324
  !isStreaming;
328
325
 
329
- if (!canStartReconnectTimer) {
330
- // timer 조건이 사라지면 재연결 가능 표시도 즉시 닫는다.
331
- setPostConnectedReconnectReady(false);
332
- return;
333
- }
326
+ if (!canStartReconnectTimer) return;
334
327
 
335
- // 장애 reason이 들어올 때마다 grace/stagger 대기 상태부터 다시 시작한다.
336
- setPostConnectedReconnectReady(false);
328
+ const nextReconnectReadyKey = `${streamIdentityKey}|${reconnectReason}`;
337
329
  const reconnectDelayMs = getPostConnectedReconnectDelayMs(
338
- `${streamIdentityKey}|${reconnectReason}`,
330
+ nextReconnectReadyKey,
339
331
  );
340
332
 
341
333
  // cam identity 기반 delay 이후에만 UI/auto reconnect가 재시도할 수 있게 한다.
342
334
  const timeout = setTimeout(() => {
343
- setPostConnectedReconnectReady(true);
335
+ setReconnectReadyKey(nextReconnectReadyKey);
344
336
  }, reconnectDelayMs);
345
337
 
346
338
  return () => {
@@ -404,13 +396,10 @@ export function useCctvRtcStream({
404
396
  );
405
397
  }, [streamKeyCandidate, streamRegistry]);
406
398
 
407
- const canUseTokenForStream = useMemo(() => {
399
+ const canUseTokenForStream = (() => {
408
400
  // token 또는 streamKey 후보가 없으면 WHEP start 조건이 닫힌다.
409
401
  if (!streamKeyCandidate || !tokenQuery.data?.token) return false;
410
402
 
411
- // 현재 hook이 이미 붙어 있는 streamKey면 재판정 없이 유지한다.
412
- if (activeStreamKeyRef.current === streamKeyCandidate) return true;
413
-
414
403
  // registry에 재사용 가능한 stream이 있으면 stale token이어도 새 WHEP POST 없이 attach만 한다.
415
404
  if (hasReusableRegistryStream) return true;
416
405
 
@@ -419,12 +408,7 @@ export function useCctvRtcStream({
419
408
  dataUpdatedAt: tokenQuery.dataUpdatedAt,
420
409
  token: tokenQuery.data.token,
421
410
  });
422
- }, [
423
- hasReusableRegistryStream,
424
- streamKeyCandidate,
425
- tokenQuery.data?.token,
426
- tokenQuery.dataUpdatedAt,
427
- ]);
411
+ })();
428
412
 
429
413
  // streamKey가 빈 문자열이면 아래 stream effect가 WHEP start를 하지 않는다.
430
414
  const streamKey = canUseTokenForStream ? streamKeyCandidate : "";
@@ -478,10 +462,6 @@ export function useCctvRtcStream({
478
462
  // DOM video에는 registry cleanup과 별개로 srcObject 잔상을 즉시 비운다.
479
463
  if (currentVideo) currentVideo.srcObject = null;
480
464
 
481
- // UI state도 초기 연결 전 상태로 되돌린다.
482
- setConnectionState("new");
483
- setStreamError(null);
484
- setStreaming(false);
485
465
  return;
486
466
  }
487
467
 
@@ -527,13 +507,14 @@ export function useCctvRtcStream({
527
507
 
528
508
  // registry snapshot 변경을 hook local state와 DOM video에 반영한다.
529
509
  const unsubscribe = streamRegistry.subscribe(streamKey, snapshot => {
510
+ setStreamStateIdentityKey(streamIdentityKey);
530
511
  setConnectionState(snapshot.connectionState);
531
512
  setStreamError(snapshot.streamError);
532
513
  setStreaming(snapshot.isStreaming);
533
514
 
534
515
  // 한 번이라도 connected가 되면 이후 장애를 post-connected recovery로 취급한다.
535
516
  if (snapshot.connectionState === "connected") {
536
- setHasConnected(true);
517
+ setConnectedIdentityKey(streamIdentityKey);
537
518
  }
538
519
 
539
520
  // onTrack으로 MediaStream이 들어온 뒤에만 video.srcObject를 교체한다.
@@ -549,11 +530,6 @@ export function useCctvRtcStream({
549
530
 
550
531
  // subscribe 직후 현재 snapshot을 즉시 반영해 기존 registry stream reattach 지연을 줄인다.
551
532
  const snapshot = streamRegistry.getSnapshot(streamKey);
552
- setConnectionState(snapshot.connectionState);
553
- setStreamError(snapshot.streamError);
554
- setStreaming(snapshot.isStreaming);
555
- if (snapshot.connectionState === "connected") setHasConnected(true);
556
-
557
533
  if (snapshot.stream) {
558
534
  // 이미 registry에 MediaStream이 있으면 첫 render cycle에서도 즉시 video에 붙인다.
559
535
  if (currentVideo.srcObject !== snapshot.stream) {
@@ -601,11 +577,11 @@ export function useCctvRtcStream({
601
577
  if (!reconnectReason) return false;
602
578
 
603
579
  // grace/stagger timer가 끝난 뒤에만 public reconnect 가능 상태를 연다.
604
- return isPostConnectedReconnectReady;
580
+ return reconnectReadyKey === `${streamIdentityKey}|${reconnectReason}`;
605
581
  }, [
606
582
  cam?.cam_online,
607
583
  hasConnected,
608
- isPostConnectedReconnectReady,
584
+ reconnectReadyKey,
609
585
  isStreaming,
610
586
  isTokenLoading,
611
587
  reconnectReason,
@@ -726,7 +702,6 @@ export function useCctvRtcStream({
726
702
  if (!camId) return;
727
703
 
728
704
  // 같은 cam을 여러 위치에서 렌더할 수 있어 instance 단위 live state를 보관한다.
729
- const instanceKey = instanceKeyRef.current as string;
730
705
  setLiveRegistry(prev => {
731
706
  // jotai atom update는 기존 cam map을 얕은 복사해 React 변경 감지를 보장한다.
732
707
  const next = { ...prev };
@@ -735,14 +710,13 @@ export function useCctvRtcStream({
735
710
  next[camId] = perCam;
736
711
  return next;
737
712
  });
738
- }, [cam?.cam_id, liveState, setLiveRegistry]);
713
+ }, [cam?.cam_id, instanceKey, liveState, setLiveRegistry]);
739
714
 
740
715
  useEffect(() => {
741
716
  // unmount cleanup도 cam_id 기준이라 cam이 없으면 등록할 cleanup이 없다.
742
717
  const camId = cam?.cam_id;
743
718
  if (!camId) return;
744
719
 
745
- const instanceKey = instanceKeyRef.current as string;
746
720
  return () => {
747
721
  // 현재 hook instance만 제거하고 같은 cam의 다른 렌더 위치는 유지한다.
748
722
  setLiveRegistry(prev => {
@@ -759,7 +733,7 @@ export function useCctvRtcStream({
759
733
  return next;
760
734
  });
761
735
  };
762
- }, [cam?.cam_id, setLiveRegistry]);
736
+ }, [cam?.cam_id, instanceKey, setLiveRegistry]);
763
737
 
764
738
  // 호출자에게 video ref와 상태값, 토큰 쿼리 상태를 전달한다.
765
739
  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);