Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion index.html
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
<!doctype html>
<html lang="en">
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta
name="description"
content="한국투자증권 Open API 기반 실시간 시세로 모의투자를 경험하세요. 실시간 거래, 타임머신 투자, AI 시장 브리핑을 제공합니다."
/>
<meta property="og:type" content="website" />
<meta property="og:title" content="WeFin — 과거에서 배우고, 현재에 투자하는 모의투자" />
<meta property="og:description" content="실시간 모의투자 트레이딩 시스템" />
<meta property="og:url" content="https://www.wefin.ai.kr" />
<meta property="og:image" content="https://www.wefin.ai.kr/og-image.png" />
<meta name="twitter:card" content="summary_large_image" />
<script>
window.global = window
;(function () {
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"lucide-react": "^1.6.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-helmet-async": "^3.0.0",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

react-helmet-async 3.0.0 React 19 meta tag deduplication behavior

💡 Result:

In react-helmet-async version 3.0.0 (released March 3, 2026), when used with React 19, there is no meta tag deduplication. The library detects React version at runtime and switches behavior: for React 19+, Helmet renders native JSX elements (title, meta, link, etc.) that React automatically hoists to , acting as a transparent passthrough without the library's prior deduplication logic. For React 16-18, the original deduplication behavior (collecting instances across the component tree, removing duplicates by key attributes like name/property, and applying via manual DOM manipulation) is preserved. This change aligns with React 19's native metadata hoisting, which does not deduplicate tags—multiple renders result in duplicates in , with later ones potentially overriding earlier based on browser behavior (e.g., last title wins, but multiple metas may persist). Features like prioritizeSeoTags and SSR context serialization have no effect on React 19 path. If deduplication is needed with React 19, alternatives like @dr.pogodin/react-helmet maintain it, or manual logic is required.

Citations:


react-helmet-async v3.0.0 + React 19에서 메타 태그 중복 발생 위험

v3.0.0은 React 19에서 HelmetProvider를 투명한 통과층으로 만들고, <Helmet>의 메타 태그들을 네이티브 JSX로 렌더링하여 React 19의 헤드 호이스팅에 위임합니다. 이 과정에서 라이브러리의 메타 태그 중복 제거 로직이 작동하지 않아, index.html의 정적 description, og:*, twitter:card 태그와 SeoHead가 주입하는 동일 속성 태그가 모두 <head>에 렌더링됩니다.

SNS 크롤러가 어떤 태그를 우선으로 사용할지는 브라우저 동작에 따라 달라지므로, 페이지별 OG 메타데이터가 의도와 다르게 인식될 수 있습니다.

다음 중 하나를 선택하세요:

  • index.html의 메타 태그를 최소 필수(예: 기본 og:image, 사이트명)만 유지하고 나머지는 SeoHead에서 관리
  • 또는 React 19 네이티브 메타 호이스팅이나 @dr.pogodin/react-helmet 같은 중복 제거를 보장하는 대안 검토
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@package.json` at line 38, react-helmet-async v3.0.0 changes cause duplicate
meta tags in React 19 because HelmetProvider becomes transparent and <Helmet>
meta tags are hoisted to native JSX, bypassing the library's
duplicate-filtering; fix by choosing one approach: either strip index.html down
to only essential static meta tags (e.g., basic og:image and site name) and let
your SeoHead component own per-page description/og/twitter tags, or replace
react-helmet-async with an alternative that guarantees duplicate suppression
(e.g., `@dr.pogodin/react-helmet`) and update usages of HelmetProvider and
<Helmet>/SeoHead accordingly so only one source injects per-page OG/meta tags.

"react-router-dom": "^7.13.2",
"recharts": "^3.8.1",
"sockjs-client": "^1.6.1",
Expand Down
40 changes: 40 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Binary file added public/og-image.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
121 changes: 121 additions & 0 deletions public/og-image.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 9 additions & 0 deletions public/robots.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
User-agent: *
Allow: /
Disallow: /account
Disallow: /settings
Disallow: /admin
Disallow: /chat
Disallow: /payment
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

Sitemap: https://www.wefin.ai.kr/sitemap.xml
33 changes: 33 additions & 0 deletions public/sitemap.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://www.wefin.ai.kr/</loc>
<changefreq>daily</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://www.wefin.ai.kr/stocks</loc>
<changefreq>daily</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>https://www.wefin.ai.kr/history</loc>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://www.wefin.ai.kr/about</loc>
<changefreq>monthly</changefreq>
<priority>0.5</priority>
</url>
<url>
<loc>https://www.wefin.ai.kr/terms</loc>
<changefreq>monthly</changefreq>
<priority>0.3</priority>
</url>
<url>
<loc>https://www.wefin.ai.kr/privacy</loc>
<changefreq>monthly</changefreq>
<priority>0.3</priority>
</url>
</urlset>
53 changes: 28 additions & 25 deletions src/app/providers/app-providers.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import type { PropsWithChildren } from 'react'
import { HelmetProvider } from 'react-helmet-async'
import { Toaster } from 'sonner'

import ErrorBoundary from './error-boundary'
Expand All @@ -17,31 +18,33 @@ const queryClient = new QueryClient({
function AppProviders({ children }: PropsWithChildren) {
return (
<ErrorBoundary>
<QueryClientProvider client={queryClient}>
{children}
<Toaster
position="top-center"
style={{ zIndex: 9999 }}
toastOptions={{
style: {
borderRadius: '16px',
padding: '12px 20px',
fontSize: '14px',
fontWeight: 600,
background: 'var(--surface)',
color: 'var(--text)',
boxShadow: '0 8px 32px -8px rgba(0,0,0,0.2)',
border: '1px solid var(--line)'
},
classNames: {
success: '!bg-wefin-mint !text-white !border-wefin-mint-deep/30',
error: '!bg-wefin-red !text-white !border-wefin-red/30',
warning: '!bg-wefin-amber !text-white !border-wefin-amber/30'
}
}}
offset={20}
/>
</QueryClientProvider>
<HelmetProvider>
<QueryClientProvider client={queryClient}>
{children}
<Toaster
position="top-center"
style={{ zIndex: 9999 }}
toastOptions={{
style: {
borderRadius: '16px',
padding: '12px 20px',
fontSize: '14px',
fontWeight: 600,
background: 'var(--surface)',
color: 'var(--text)',
boxShadow: '0 8px 32px -8px rgba(0,0,0,0.2)',
border: '1px solid var(--line)'
},
classNames: {
success: '!bg-wefin-mint !text-white !border-wefin-mint-deep/30',
error: '!bg-wefin-red !text-white !border-wefin-red/30',
warning: '!bg-wefin-amber !text-white !border-wefin-amber/30'
}
}}
offset={20}
/>
</QueryClientProvider>
</HelmetProvider>
</ErrorBoundary>
)
}
Expand Down
3 changes: 3 additions & 0 deletions src/features/news-feed/ui/cluster-detail-content.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { useParams } from 'react-router-dom'

import SeoHead from '@/shared/ui/seo-head'

import { useClusterDetailQuery } from '../model/use-cluster-detail-query'
import { useMarkClusterRead } from '../model/use-mark-cluster-read'
import ClusterDetailFooter from './cluster-detail-footer'
Expand Down Expand Up @@ -55,6 +57,7 @@ export default function ClusterDetailContent() {

return (
<div className="overflow-hidden rounded-2xl bg-wefin-surface p-4 sm:rounded-3xl sm:p-8">
<SeoHead title={cluster.title} description={cluster.summary} path={`/news/${clusterId}`} />

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

description으로 전달되는 cluster.summary 길이 고려 필요.

검색/SNS 크롤러는 meta description을 대략 150~160자, og:description도 비슷한 범위로만 표시합니다. 기사 요약은 이를 초과할 가능성이 높아 잘린 채 노출될 수 있습니다. 또한 요약에 개행/따옴표가 포함되면 렌더된 메타 속성 값이 지저분해질 수 있으니 정규화(공백 압축) + 하드 트렁케이트를 SeoHead 또는 여기서 적용하는 것을 권장합니다.

💡 예시 구현
-      <SeoHead title={cluster.title} description={cluster.summary} path={`/news/${clusterId}`} />
+      <SeoHead
+        title={cluster.title}
+        description={truncate(cluster.summary.replace(/\s+/g, ' ').trim(), 155)}
+        path={`/news/${clusterId}`}
+      />

추가로 clusterId는 URL에서 온 문자열이며 numericClusterIdNaN일 수도 있습니다. path에는 원문 문자열을 넣고 있어 /news/abc 같은 경로가 canonical로 노출될 수 있으니, 유효성 체크(Number.isFinite(numericClusterId)) 후 렌더하는 것이 안전합니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/news-feed/ui/cluster-detail-content.tsx` at line 60, Normalize
and hard-truncate the description before passing to SeoHead: take
cluster.summary, collapse consecutive whitespace/newlines into single spaces,
strip problematic characters like unescaped quotes, then truncate to ~155
characters and pass that as description to SeoHead (use cluster.title
unchanged); also validate clusterId by converting to a number (e.g.,
numericClusterId = Number(clusterId)) and only render a numeric canonical path
when Number.isFinite(numericClusterId) (e.g., `/news/${numericClusterId}`),
otherwise omit or fallback to a safe non-numeric path to avoid exposing
`/news/abc` as canonical.

<ClusterDetailHeader cluster={cluster} />
<ClusterDetailSections sections={cluster.sections} articleContent={cluster.articleContent} />
<ClusterDetailFooter cluster={cluster} />
Expand Down
2 changes: 2 additions & 0 deletions src/pages/home/ui/home-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import NewsFeedSection from '@/features/news-feed/ui/news-feed-section'
import NewsListSection from '@/features/news-feed/ui/news-list-section'
import RecommendedNewsSection from '@/features/recommended-news/ui/recommended-news-section'
import ResizeHandle from '@/features/stock-detail/ui/resize-handle'
import SeoHead from '@/shared/ui/seo-head'
import HomeSidebar from '@/widgets/home-sidebar/ui/home-sidebar'

const SIDEBAR_WIDTH_STORAGE_KEY = 'home-sidebar-width'
Expand Down Expand Up @@ -48,6 +49,7 @@ function HomePage() {

return (
<div ref={containerRef} className="flex flex-col gap-[var(--gutter)] lg:flex-row">
<SeoHead path="/" />
<section className="min-w-0 flex-1">
<div className="flex flex-col gap-[var(--gutter)]">
<HeroSection />
Expand Down
Loading
Loading