Skip to content

Commit 4d50bd9

Browse files
committed
fix(react): use stable keys for dynamic lists
1 parent 10ff622 commit 4d50bd9

48 files changed

Lines changed: 536 additions & 196 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/docs/app/api/og/route.tsx

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,12 @@ function splitOversizedWord(word: string, maxWidthEm: number): string[] {
102102
* so it sidesteps the bug instead of fighting Satori's own line-wrapping
103103
* (which is also disabled here — lines are pre-split, not auto-wrapped).
104104
*/
105-
function wrapTitleLines(title: string, fontSize: number): string[] {
105+
interface TitleLine {
106+
text: string
107+
sourceOffset: number
108+
}
109+
110+
function wrapTitleLines(title: string, fontSize: number): TitleLine[] {
106111
const maxWidthEm = TITLE_BOX_WIDTH / fontSize
107112
const words = title.split(' ')
108113
const lines: string[] = []
@@ -130,7 +135,12 @@ function wrapTitleLines(title: string, fontSize: number): string[] {
130135
}
131136
if (current) lines.push(current)
132137

133-
return lines.map((line) => line.replace(/ /g, ' '))
138+
let lineOffset = 0
139+
return lines.map((line) => {
140+
const sourceOffset = lineOffset
141+
lineOffset += line.length + 1
142+
return { text: line.replace(/ /g, ' '), sourceOffset }
143+
})
134144
}
135145

136146
/**
@@ -211,8 +221,8 @@ export async function GET(request: NextRequest) {
211221
</div>
212222

213223
<div style={getTitleStyle(title)}>
214-
{titleLines.map((line, index) => (
215-
<span key={index}>{line}</span>
224+
{titleLines.map((line) => (
225+
<span key={line.sourceOffset}>{line.text}</span>
216226
))}
217227
</div>
218228
</div>,

apps/docs/components/workflow-preview/format-references.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,19 @@ const REFERENCE_PATTERN = /(<[^<>]+>|\{\{[^{}]+\}\})/g
1010
*/
1111
export function formatReferences(text: string): ReactNode[] {
1212
if (!text) return []
13-
return text.split(REFERENCE_PATTERN).map((part, index) => {
13+
let sourceOffset = 0
14+
return text.split(REFERENCE_PATTERN).map((part) => {
15+
const partOffset = sourceOffset
16+
sourceOffset += part.length
1417
if (!part) return null
1518
const isReference =
1619
(part.startsWith('<') && part.endsWith('>')) || (part.startsWith('{{') && part.endsWith('}}'))
1720
return isReference ? (
18-
<span key={index} className='text-[var(--brand-secondary)]'>
21+
<span key={partOffset} className='text-[var(--brand-secondary)]'>
1922
{part}
2023
</span>
2124
) : (
22-
<span key={index}>{part}</span>
25+
<span key={partOffset}>{part}</span>
2326
)
2427
})
2528
}

apps/sim/app/(interfaces)/chat/components/input/input.tsx

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ interface AttachedFile {
2121
dataUrl?: string
2222
}
2323

24+
interface UploadError {
25+
id: string
26+
message: string
27+
}
28+
2429
export const ChatInput: React.FC<{
2530
onSubmit?: (value: string, files?: AttachedFile[]) => void
2631
isStreaming?: boolean
@@ -30,7 +35,7 @@ export const ChatInput: React.FC<{
3035
const textareaRef = useRef<HTMLTextAreaElement>(null)
3136
const [inputValue, setInputValue] = useState('')
3237
const [attachedFiles, setAttachedFiles] = useState<AttachedFile[]>([])
33-
const [uploadErrors, setUploadErrors] = useState<string[]>([])
38+
const [uploadErrors, setUploadErrors] = useState<UploadError[]>([])
3439
const [dragCounter, setDragCounter] = useState(0)
3540
const isDragOver = dragCounter > 0
3641

@@ -54,15 +59,21 @@ export const ChatInput: React.FC<{
5459
const file = selectedFiles[i]
5560

5661
if (file.size > maxSize) {
57-
setUploadErrors((prev) => [...prev, `${file.name} is too large (max 10MB)`])
62+
setUploadErrors((prev) => [
63+
...prev,
64+
{ id: generateId(), message: `${file.name} is too large (max 10MB)` },
65+
])
5866
continue
5967
}
6068

6169
const isDuplicate = attachedFiles.some(
6270
(existing) => existing.name === file.name && existing.size === file.size
6371
)
6472
if (isDuplicate) {
65-
setUploadErrors((prev) => [...prev, `${file.name} already added`])
73+
setUploadErrors((prev) => [
74+
...prev,
75+
{ id: generateId(), message: `${file.name} already added` },
76+
])
6677
continue
6778
}
6879

@@ -128,9 +139,9 @@ export const ChatInput: React.FC<{
128139
<div className='w-full max-w-3xl md:max-w-[748px]'>
129140
{uploadErrors.length > 0 && (
130141
<div className='mb-3 flex flex-col gap-2'>
131-
{uploadErrors.map((error, idx) => (
132-
<Badge key={`${error}-${idx}`} variant='red' size='lg' dot className='max-w-full'>
133-
{error}
142+
{uploadErrors.map((error) => (
143+
<Badge key={error.id} variant='red' size='lg' dot className='max-w-full'>
144+
{error.message}
134145
</Badge>
135146
))}
136147
</div>

apps/sim/app/(landing)/components/hero/components/hero-visual/stage-home.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -114,11 +114,11 @@ const Caret = () => (
114114
function PromptAtoms({ atoms }: { atoms: PromptAtom[] }) {
115115
return (
116116
<>
117-
{atoms.map((atom, i) =>
117+
{atoms.map((atom) =>
118118
atom.kind === 'char' ? (
119-
<span key={`${i}-${atom.char}`}>{atom.char}</span>
119+
<span key={atom.id}>{atom.char}</span>
120120
) : (
121-
<span key={`${i}-${atom.label}`}>
121+
<span key={atom.id}>
122122
<span className='relative'>
123123
<span className='invisible'>@</span>
124124
<atom.icon className='absolute inset-0 m-auto size-[12px] translate-y-[1.25px] text-[var(--text-icon)]' />

apps/sim/app/(landing)/components/hero/components/hero-visual/workflow-data.ts

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -238,8 +238,8 @@ export const SCENE_JIRA_FOCUS_TRANSLATE = { x: -645, y: 0 } as const
238238
* `@GitHub` / `@Jira` mention.
239239
*/
240240
export type PromptAtom =
241-
| { kind: 'char'; char: string }
242-
| { kind: 'mention'; label: string; icon: IconComponent }
241+
| { id: string; kind: 'char'; char: string }
242+
| { id: string; kind: 'mention'; label: string; icon: IconComponent }
243243

244244
const PROMPT_SEGMENTS: Array<string | { label: string; icon: IconComponent }> = [
245245
'Create me a ',
@@ -248,11 +248,26 @@ const PROMPT_SEGMENTS: Array<string | { label: string; icon: IconComponent }> =
248248
{ label: 'Jira', icon: JiraIcon },
249249
]
250250

251-
export const PROMPT_ATOMS: PromptAtom[] = PROMPT_SEGMENTS.flatMap((seg) =>
252-
typeof seg === 'string'
253-
? [...seg].map((char): PromptAtom => ({ kind: 'char', char }))
254-
: [{ kind: 'mention', label: seg.label, icon: seg.icon } as PromptAtom]
255-
)
251+
let promptSourceOffset = 0
252+
export const PROMPT_ATOMS: PromptAtom[] = PROMPT_SEGMENTS.flatMap((seg) => {
253+
if (typeof seg === 'string') {
254+
const atoms = [...seg].map((char): PromptAtom => {
255+
const id = `char-${promptSourceOffset}`
256+
promptSourceOffset += char.length
257+
return { id, kind: 'char', char }
258+
})
259+
return atoms
260+
}
261+
262+
const atom: PromptAtom = {
263+
id: `mention-${promptSourceOffset}-${seg.label}`,
264+
kind: 'mention',
265+
label: seg.label,
266+
icon: seg.icon,
267+
}
268+
promptSourceOffset += seg.label.length + 1
269+
return [atom]
270+
})
256271

257272
/** Greeting shown above the input in the home state (matches the Mothership home). */
258273
export const HOME_GREETING = 'What should we get done?'

apps/sim/app/(landing)/components/landing-preview/components/landing-preview-workflow/preview-block-node.tsx

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -287,22 +287,27 @@ export const PreviewBlockNode = memo(function PreviewBlockNode({
287287
* Supports ### headings, **bold**, _italic_, --- rules, and blank-line spacing.
288288
*/
289289
function NoteMarkdown({ content }: { content: string }) {
290-
const lines = content.split('\n')
290+
let sourceOffset = 0
291+
const lines = content.split('\n').map((text) => {
292+
const line = { sourceOffset, text }
293+
sourceOffset += text.length + 1
294+
return line
295+
})
291296

292297
return (
293298
<div className='flex flex-col gap-1'>
294-
{lines.map((line, i) => {
295-
const trimmed = line.trim()
296-
if (!trimmed) return <div key={`${line}-${i}`} className='h-[4px]' />
299+
{lines.map((line) => {
300+
const trimmed = line.text.trim()
301+
if (!trimmed) return <div key={line.sourceOffset} className='h-[4px]' />
297302

298303
if (trimmed === '---') {
299-
return <hr key={`${line}-${i}`} className='my-1 border-[var(--border)] border-t' />
304+
return <hr key={line.sourceOffset} className='my-1 border-[var(--border)] border-t' />
300305
}
301306

302307
if (trimmed.startsWith('### ')) {
303308
return (
304309
<p
305-
key={`${line}-${i}`}
310+
key={line.sourceOffset}
306311
className='font-semibold text-[16px] text-[var(--text-primary)] leading-[1.3]'
307312
>
308313
{trimmed.slice(4)}
@@ -312,7 +317,7 @@ function NoteMarkdown({ content }: { content: string }) {
312317

313318
return (
314319
<p
315-
key={`${line}-${i}`}
320+
key={line.sourceOffset}
316321
className='font-medium text-[13px] text-[var(--text-primary)] leading-[1.5]'
317322
dangerouslySetInnerHTML={{
318323
__html: trimmed

apps/sim/app/(landing)/components/prose-page/components/legal-block-group/components/legal-block/legal-block.tsx

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { cn } from '@sim/emcn'
2+
import { extractTextContent } from '@/lib/core/utils/react-node-text'
23
import { PROSE_SPACING, PROSE_TYPE } from '@/app/(landing)/components/prose-page/constants'
34
import type { LegalBlock } from '@/app/(landing)/components/prose-page/types'
45

@@ -21,19 +22,23 @@ export function LegalBlockView({ block }: LegalBlockViewProps) {
2122
return <p className={PROSE_TYPE.body}>{block.content}</p>
2223
case 'subheading':
2324
return <h3 className={PROSE_TYPE.h3}>{block.text}</h3>
24-
case 'list':
25+
case 'list': {
26+
const itemOccurrences = new Map<string, number>()
2527
return (
2628
<ul className={cn('list-disc', PROSE_SPACING.listIndent, PROSE_SPACING.listStack)}>
27-
{block.items.map((item, index) => {
28-
const itemKey = `item-${index}`
29+
{block.items.map((item) => {
30+
const signature = extractTextContent(item)
31+
const occurrence = itemOccurrences.get(signature) ?? 0
32+
itemOccurrences.set(signature, occurrence + 1)
2933
return (
30-
<li key={itemKey} className={PROSE_TYPE.list}>
34+
<li key={`${signature}:${occurrence}`} className={PROSE_TYPE.list}>
3135
{item}
3236
</li>
3337
)
3438
})}
3539
</ul>
3640
)
41+
}
3742
case 'callout':
3843
return <div className={PROSE_TYPE.callout}>{block.content}</div>
3944
case 'table':

apps/sim/app/(landing)/components/prose-page/components/legal-block-group/legal-block-group.tsx

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { cn } from '@sim/emcn'
2+
import { extractTextContent } from '@/lib/core/utils/react-node-text'
23
import { LegalBlockView } from '@/app/(landing)/components/prose-page/components/legal-block-group/components'
34
import { PROSE_SPACING } from '@/app/(landing)/components/prose-page/constants'
45
import type { LegalBlock } from '@/app/(landing)/components/prose-page/types'
@@ -16,11 +17,21 @@ interface LegalBlockGroupProps {
1617
}
1718

1819
export function LegalBlockGroup({ blocks }: LegalBlockGroupProps) {
20+
const blockOccurrences = new Map<string, number>()
1921
return (
2022
<div className={cn('flex flex-col', PROSE_SPACING.blockStack)}>
21-
{blocks.map((block, index) => (
22-
<LegalBlockView key={`${block.kind}-${index}`} block={block} />
23-
))}
23+
{blocks.map((block) => {
24+
const content =
25+
block.kind === 'subheading'
26+
? block.text
27+
: block.kind === 'list'
28+
? block.items.map(extractTextContent).join('\u0000')
29+
: extractTextContent(block.content)
30+
const signature = `${block.kind}:${content}`
31+
const occurrence = blockOccurrences.get(signature) ?? 0
32+
blockOccurrences.set(signature, occurrence + 1)
33+
return <LegalBlockView key={`${signature}:${occurrence}`} block={block} />
34+
})}
2435
</div>
2536
)
2637
}

apps/sim/app/(landing)/enterprise/components/feature-graphics/build-methods-graphic.tsx

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ interface CodeSegment {
1313
}
1414

1515
/** The `support-agent.ts` contents, split into tone-colored typewriter segments. */
16-
const CODE_LINES: CodeSegment[][] = [
16+
const RAW_CODE_LINES: CodeSegment[][] = [
1717
[
1818
{ text: 'import', tone: 'muted' },
1919
{ text: ' ' },
@@ -52,8 +52,24 @@ const CODE_LINES: CodeSegment[][] = [
5252
[{ text: ' })' }],
5353
]
5454

55+
const codeLineOccurrences = new Map<string, number>()
56+
const CODE_LINES = RAW_CODE_LINES.map((segments) => {
57+
const text = segments.map((segment) => segment.text).join('')
58+
const occurrence = codeLineOccurrences.get(text) ?? 0
59+
codeLineOccurrences.set(text, occurrence + 1)
60+
let sourceOffset = 0
61+
return {
62+
id: `${text}:${occurrence}`,
63+
segments: segments.map((segment) => {
64+
const id = `${sourceOffset}:${segment.text.length}`
65+
sourceOffset += segment.text.length
66+
return { ...segment, id }
67+
}),
68+
}
69+
})
70+
5571
const CODE_LINE_LENGTHS = CODE_LINES.map((line) =>
56-
line.reduce((total, segment) => total + segment.text.length, 0)
72+
line.segments.reduce((total, segment) => total + segment.text.length, 0)
5773
)
5874
const CODE_LINE_STARTS = CODE_LINE_LENGTHS.map((_, index) =>
5975
CODE_LINE_LENGTHS.slice(0, index).reduce((total, length) => total + length, 0)
@@ -93,13 +109,13 @@ const SEGMENT_TONE_CLASS = {
93109
} as const
94110

95111
/** Renders one code line clipped to the number of characters typed so far. */
96-
function renderCodeLine(segments: CodeSegment[], visibleChars: number) {
112+
function renderCodeLine(segments: Array<CodeSegment & { id: string }>, visibleChars: number) {
97113
const rendered = []
98114
let remaining = visibleChars
99115
for (let index = 0; index < segments.length && remaining > 0; index++) {
100116
const segment = segments[index]
101117
rendered.push(
102-
<span key={index} className={segment.tone && SEGMENT_TONE_CLASS[segment.tone]}>
118+
<span key={segment.id} className={segment.tone && SEGMENT_TONE_CLASS[segment.tone]}>
103119
{segment.text.slice(0, remaining)}
104120
</span>
105121
)
@@ -255,12 +271,12 @@ export function BuildMethodsGraphic() {
255271
<div className='min-h-[190px] space-y-2 p-4 font-mono text-caption leading-[1.7]'>
256272
{CODE_LINES.map((line, index) =>
257273
typedCodeChars > CODE_LINE_STARTS[index] ? (
258-
<div key={index} className='flex gap-3'>
274+
<div key={line.id} className='flex gap-3'>
259275
<span className='w-3 select-none text-right text-[var(--text-muted)]'>
260276
{index + 1}
261277
</span>
262278
<code>
263-
{renderCodeLine(line, typedCodeChars - CODE_LINE_STARTS[index])}
279+
{renderCodeLine(line.segments, typedCodeChars - CODE_LINE_STARTS[index])}
264280
{codeTypingActive && index === lastStartedLine && (
265281
<span className='ml-px inline-block h-[1.1em] w-px translate-y-[2px] animate-pulse bg-[var(--text-primary)] align-text-bottom' />
266282
)}

0 commit comments

Comments
 (0)