diff --git a/src/pages/stock/components/WatchlistTab.jsx b/src/pages/stock/components/WatchlistTab.jsx
index 08038f5..e762597 100644
--- a/src/pages/stock/components/WatchlistTab.jsx
+++ b/src/pages/stock/components/WatchlistTab.jsx
@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import Loading from '../../../components/Loading';
-import { kindMeta, conditionLabel, relativeTime } from '../watchlistUtils';
+import { kindMeta, conditionLabel, relativeTime, formatDetail } from '../watchlistUtils';
import { formatNumber } from '../stockUtils';
const DAYS_OPTIONS = [
@@ -11,6 +11,7 @@ const DAYS_OPTIONS = [
const AlertCard = ({ a }) => {
const meta = kindMeta(a.kind);
+ const detailText = formatDetail(a.detail);
return (
@@ -23,7 +24,7 @@ const AlertCard = ({ a }) => {
{conditionLabel(a.condition)}
{a.price != null && {formatNumber(a.price)}원}
- {a.detail &&
{a.detail}
}
+ {detailText &&
{detailText}
}
);
};
diff --git a/src/pages/stock/components/WatchlistTab.test.jsx b/src/pages/stock/components/WatchlistTab.test.jsx
index ecc4574..a99ddf3 100644
--- a/src/pages/stock/components/WatchlistTab.test.jsx
+++ b/src/pages/stock/components/WatchlistTab.test.jsx
@@ -28,4 +28,20 @@ describe('WatchlistTab', () => {
expect(screen.getByText('매수')).toBeInTheDocument();
expect(screen.getByText('박스 상단 돌파')).toBeInTheDocument();
});
+
+ it('detail이 객체인 알림도 크래시 없이 렌더 (React #31 회귀 방지)', () => {
+ const wl = {
+ ...baseWl,
+ alerts: [{
+ id: 20, ticker: '381170', name: null, kind: 'sell', condition: 'sell_ma_break',
+ price: 32715, detail: { ma50: 33309.8, ma200: null, severity: 'normal' },
+ fired_at: '2026-07-03T07:02:59Z',
+ }],
+ };
+ render();
+ expect(screen.getByText('매도')).toBeInTheDocument();
+ expect(screen.getByText('이평선 이탈')).toBeInTheDocument();
+ // 객체 detail이 문자열로 안전 렌더됨 (severity 값 노출)
+ expect(screen.getByText(/normal/)).toBeInTheDocument();
+ });
});
diff --git a/src/pages/stock/watchlistUtils.js b/src/pages/stock/watchlistUtils.js
index 25dce70..9841430 100644
--- a/src/pages/stock/watchlistUtils.js
+++ b/src/pages/stock/watchlistUtils.js
@@ -28,6 +28,39 @@ export const conditionLabel = (cond) =>
export const normalizeTicker = (str) => String(str ?? '').trim();
+/* 알림 detail 은 condition 마다 스키마가 다른 객체(또는 문자열).
+ 객체를 JSX 자식으로 직접 렌더하면 React #31 크래시 → 항상 문자열로 변환한다.
+ 특정 필드 존재를 가정하지 않고(스키마 가변) 알려진 키만 한글 라벨, 나머지는 원문 키. */
+const DETAIL_KEY_LABEL = Object.freeze({
+ avg_price: '평단', pnl_pct: '손익', stop_pct: '손절', take_pct: '목표',
+ holding_high: '보유고점', trailing_pct: '트레일링', drawdown_pct: '낙폭',
+ ma50: 'MA50', ma200: 'MA200', severity: '강도', vol: '거래량', rsi: 'RSI',
+ breakout_high: '돌파고점', pullback_pct: '눌림',
+});
+
+const formatDetailValue = (key, v) => {
+ if (v == null) return null;
+ if (typeof v === 'number') {
+ if (key.endsWith('_pct')) return `${(v * 100).toFixed(2)}%`;
+ return Number.isInteger(v) ? v.toLocaleString('ko-KR') : String(v);
+ }
+ if (typeof v === 'object') return JSON.stringify(v);
+ return String(v);
+};
+
+export const formatDetail = (detail) => {
+ if (detail == null) return '';
+ if (typeof detail === 'string') return detail;
+ if (typeof detail !== 'object') return String(detail);
+ return Object.entries(detail)
+ .map(([k, v]) => {
+ const fv = formatDetailValue(k, v);
+ return fv == null ? null : `${DETAIL_KEY_LABEL[k] ?? k} ${fv}`;
+ })
+ .filter(Boolean)
+ .join(' · ');
+};
+
export const relativeTime = (iso, now = Date.now()) => {
if (!iso) return '';
const then = new Date(iso).getTime();
diff --git a/src/pages/stock/watchlistUtils.test.js b/src/pages/stock/watchlistUtils.test.js
index edbccc6..e3b61aa 100644
--- a/src/pages/stock/watchlistUtils.test.js
+++ b/src/pages/stock/watchlistUtils.test.js
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
-import { kindMeta, conditionLabel, normalizeTicker, relativeTime } from './watchlistUtils.js';
+import { kindMeta, conditionLabel, normalizeTicker, relativeTime, formatDetail } from './watchlistUtils.js';
describe('kindMeta', () => {
it('buy/sell 라벨과 색을 반환', () => {
@@ -53,6 +53,34 @@ describe('normalizeTicker', () => {
});
});
+describe('formatDetail (알림 detail 객체 → 안전 문자열, React #31 방지)', () => {
+ it('객체 detail을 읽기 좋은 문자열로 변환하고 절대 객체를 반환하지 않는다', () => {
+ const s = formatDetail({ avg_price: 75325, pnl_pct: -0.1012, stop_pct: 0.08 });
+ expect(typeof s).toBe('string');
+ expect(s).toContain('75,325'); // avg_price 천단위 포맷
+ expect(s).toContain('-10.12%'); // *_pct 는 퍼센트로
+ expect(s).toContain('8.00%');
+ });
+ it('값이 null인 필드는 건너뛴다', () => {
+ const s = formatDetail({ ma50: 33309.8, ma200: null, severity: 'normal' });
+ expect(typeof s).toBe('string');
+ expect(s).toContain('normal');
+ expect(s).not.toContain('null');
+ });
+ it('문자열 detail은 그대로 반환', () => {
+ expect(formatDetail('박스권 돌파')).toBe('박스권 돌파');
+ });
+ it('null/undefined 는 빈 문자열', () => {
+ expect(formatDetail(null)).toBe('');
+ expect(formatDetail(undefined)).toBe('');
+ });
+ it('미정의 키(스키마 가정 없음)도 크래시 없이 문자열로', () => {
+ const s = formatDetail({ some_new_field: 42 });
+ expect(typeof s).toBe('string');
+ expect(s).toContain('42');
+ });
+});
+
describe('relativeTime', () => {
const now = new Date('2026-07-03T12:00:00Z').getTime();
it('60초 미만은 방금', () => {