Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
9 changes: 8 additions & 1 deletion ios/shared/VoltraElement.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ public struct VoltraElement: Hashable {
/// Optional identifier for the element
public let id: String?

/// Optional key for list item identity (used in ForEach)
public let key: String?

/// Child nodes or text content
public let children: VoltraNode?

Expand All @@ -25,11 +28,12 @@ public struct VoltraElement: Hashable {
public func hash(into hasher: inout Hasher) {
hasher.combine(type)
hasher.combine(id)
hasher.combine(key)
// Note: props are not included in hash for performance
}

public static func == (lhs: VoltraElement, rhs: VoltraElement) -> Bool {
lhs.type == rhs.type && lhs.id == rhs.id
lhs.type == rhs.type && lhs.id == rhs.id && lhs.key == rhs.key
}

// MARK: - Computed Properties
Expand Down Expand Up @@ -101,6 +105,9 @@ public struct VoltraElement: Hashable {
// Extract id
id = dict["i"]?.stringValue

// Extract key
key = dict["k"]?.stringValue

// Extract children
if let childrenValue = dict["c"] {
children = VoltraNode(from: childrenValue, stylesheet: stylesheet, sharedElements: sharedElements)
Expand Down
13 changes: 9 additions & 4 deletions ios/shared/VoltraNode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -90,13 +90,18 @@ public indirect enum VoltraNode: Hashable, View {
public var body: some View {
switch self {
case let .element(element):
VoltraElementView(element: element)
// Apply .id() modifier if key is present
if let key = element.key {
VoltraElementView(element: element).id(key)
} else {
VoltraElementView(element: element)
}
case let .array(nodes):
// Use stable identifiers: prefer element.id, fall back to index
// Use stable identifiers: prefer key > index fallback
let items: [(id: String, node: VoltraNode)] = nodes.enumerated().map { offset, node in
let id: String
if case let .element(element) = node, let elementId = element.id {
id = elementId
if case let .element(element) = node, let key = element.key {
id = key
} else {
id = "idx_\(offset)"
}
Expand Down
174 changes: 174 additions & 0 deletions src/renderer/__tests__/key-prop.node.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import React from 'react'

import { Text } from '../../jsx/Text'
import { VStack } from '../../jsx/VStack'
import { logger } from '../../logger'
import { VoltraElementJson } from '../../types'
import { renderVoltraVariantToJson } from '../renderer'
import { assertVoltraElement } from './test-helpers'

/**
* Key Prop Support Tests
*
* The 'key' prop provides React-like list identity management for Voltra elements.
*
* Behavior:
* - Keys are extracted from React elements and stored in the 'k' field
* - On SwiftUI side, keys are used for .id() modifier for view identity
* - For single elements: key provides stable identity across re-renders
* - For array elements: key is used in ForEach for proper list reconciliation
* - The 'id' property is separate and NOT used for view identity
* - In development mode, warns when array elements lack keys (2+ items)
*/
describe('Key Prop Support', () => {
beforeEach(() => {
jest.spyOn(logger, 'warn').mockClear()
})

afterEach(() => {
jest.restoreAllMocks()
})

test('extracts key from element', () => {
const output = renderVoltraVariantToJson(<Text key="item-1">First</Text>)
assertVoltraElement(output)
expect(output.k).toBe('item-1')
})

test('key and id can coexist (only key used for view identity)', () => {
// Both key and id can be present on the same element
// However, only key is used for SwiftUI .id() modifier for view identity
// The id property serves other purposes (accessibility, etc.)
const output = renderVoltraVariantToJson(
<Text key="k1" id="id1">
Hello
</Text>
)
assertVoltraElement(output)
expect(output.k).toBe('k1')
expect(output.i).toBe('id1')
})

test('id property does not affect view identity', () => {
// Element with id but no key - id is stored but NOT used for .id() modifier
const output = renderVoltraVariantToJson(<Text id="test-id">Hello</Text>)
assertVoltraElement(output)
expect(output.i).toBe('test-id')
expect('k' in output).toBe(false)
})

test('array items with keys', () => {
const items = ['a', 'b', 'c'].map((letter) => <Text key={letter}>Letter {letter}</Text>)
const output = renderVoltraVariantToJson(<VStack>{items}</VStack>)
assertVoltraElement(output)
expect(Array.isArray(output.c)).toBe(true)
const children = output.c as VoltraElementJson[]
expect(children[0].k).toBe('a')
expect(children[1].k).toBe('b')
expect(children[2].k).toBe('c')
})

test('warns on missing keys in development', () => {
const mockWarn = jest.spyOn(logger, 'warn')
// eslint-disable-next-line react/jsx-key
const items = [<Text>A</Text>, <Text>B</Text>]
renderVoltraVariantToJson(<VStack>{items}</VStack>)
expect(mockWarn).toHaveBeenCalledWith(expect.stringContaining('should have a unique "key" prop'))
})

test('prints warning message for array without keys', () => {
const mockWarn = jest.spyOn(logger, 'warn')
// eslint-disable-next-line react/jsx-key
const items = [<Text>Item 1</Text>, <Text>Item 2</Text>, <Text>Item 3</Text>]
renderVoltraVariantToJson(<VStack>{items}</VStack>)

expect(mockWarn).toHaveBeenCalledTimes(1)
expect(mockWarn).toHaveBeenCalledWith(
'Each child in an array should have a unique "key" prop. Keys help Voltra identify which items have changed, are added, or removed.'
)
})

test('does not print warning when array has keys', () => {
const mockWarn = jest.spyOn(logger, 'warn')
const items = [<Text key="item-1">Item 1</Text>, <Text key="item-2">Item 2</Text>, <Text key="item-3">Item 3</Text>]
renderVoltraVariantToJson(<VStack>{items}</VStack>)

expect(mockWarn).not.toHaveBeenCalled()
})

test('does not warn for single item', () => {
const mockWarn = jest.spyOn(logger, 'warn')
renderVoltraVariantToJson(
<VStack>
<Text>Single</Text>
</VStack>
)
expect(mockWarn).not.toHaveBeenCalled()
})

test('does not warn when all items have keys', () => {
const mockWarn = jest.spyOn(logger, 'warn')
const items = [<Text key="a">A</Text>, <Text key="b">B</Text>]
renderVoltraVariantToJson(<VStack>{items}</VStack>)
expect(mockWarn).not.toHaveBeenCalled()
})

test('does not warn for string context', () => {
const mockWarn = jest.spyOn(logger, 'warn')
renderVoltraVariantToJson(<Text>{['string1', 'string2']}</Text>)
expect(mockWarn).not.toHaveBeenCalled()
})

test('omits key field when not provided', () => {
const output = renderVoltraVariantToJson(<Text>No key</Text>)
assertVoltraElement(output)
expect('k' in output).toBe(false)
})

test('key preserved in large arrays', () => {
const items = Array.from({ length: 100 }, (_, i) => <Text key={`item-${i}`}>Item {i}</Text>)
const output = renderVoltraVariantToJson(<VStack>{items}</VStack>)
assertVoltraElement(output)
expect(Array.isArray(output.c)).toBe(true)
const children = output.c as VoltraElementJson[]
expect(children[0].k).toBe('item-0')
expect(children[50].k).toBe('item-50')
expect(children[99].k).toBe('item-99')
})

test('handles mixed keyed and non-keyed items', () => {
const mockWarn = jest.spyOn(logger, 'warn')
const items = [
<Text key="a">A</Text>,
// eslint-disable-next-line react/jsx-key
<Text>B</Text>,
<Text key="c">C</Text>,
]
const output = renderVoltraVariantToJson(<VStack>{items}</VStack>)
assertVoltraElement(output)
expect(Array.isArray(output.c)).toBe(true)
const children = output.c as VoltraElementJson[]
assertVoltraElement(children[0])
assertVoltraElement(children[1])
assertVoltraElement(children[2])
expect(children[0].k).toBe('a')
expect('k' in children[1]).toBe(false)
expect(children[2].k).toBe('c')
// Does not warn because at least some items have keys
expect(mockWarn).not.toHaveBeenCalled()
})

test('allows empty string as key', () => {
const output = renderVoltraVariantToJson(<Text key="">Empty key</Text>)
assertVoltraElement(output)
expect(output.k).toBe('')
})

test('numeric key converted to string by React', () => {
// React converts numeric keys to strings
const output = renderVoltraVariantToJson(<Text key={123}>Item</Text>)
// React converts numeric key to string '123'
assertVoltraElement(output)
expect(output.k).toBe('123')
})
})
30 changes: 27 additions & 3 deletions src/renderer/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
} from 'react-is'

import { isVoltraComponent } from '../jsx/createVoltraComponent.js'
import { logger } from '../logger.js'
import { getComponentId } from '../payload/component-ids.js'
import { shorten } from '../payload/short-names.js'
import { VoltraElementJson, VoltraElementRef, VoltraNodeJson, VoltraPropValue } from '../types.js'
Expand Down Expand Up @@ -96,6 +97,25 @@ function renderNode(element: ReactNode, context: VoltraRenderingContext): Voltra
}
return results.join('')
}

// Warn about missing keys in development (arrays with 2+ elements)
if (__DEV__ && element.length >= 2) {
const elementsWithKeys = element.filter((child) => {
// Check if child has a key property (React stores key on element, not in props)
if (child && typeof child === 'object' && 'key' in child) {
return child.key !== null && child.key !== undefined
}
return false
})

if (elementsWithKeys.length === 0) {
logger.warn(
'Each child in an array should have a unique "key" prop. ' +
'Keys help Voltra identify which items have changed, are added, or removed.'
)
}
}

return element.map((child) => renderNode(child, context)).flat() as VoltraNodeJson
}

Expand Down Expand Up @@ -239,11 +259,13 @@ function renderNodeInternal(element: ReactNode, context: VoltraRenderingContext)
const renderedChildren =
children !== null && children !== undefined ? renderNode(children, childContext) : isTextComponent ? '' : []

// Extract id from parameters and remove from props
// Extract id and key from parameters and remove from props
const id = typeof parameters.id === 'string' ? parameters.id : undefined
// Extract key from React element (React stores key separately, not in props)
const key = typeof reactElement.key === 'string' ? reactElement.key : undefined

// Remove id from parameters so it doesn't end up in props
const { id: _id, ...cleanParameters } = parameters
// Remove id and key from parameters so they don't end up in props
const { id: _id, key: _key, ...cleanParameters } = parameters

if (isTextComponent) {
// Text component must resolve to a string
Expand All @@ -261,6 +283,7 @@ function renderNodeInternal(element: ReactNode, context: VoltraRenderingContext)
const voltraHostElement: VoltraElementJson = {
t: getComponentId(child.type),
...(id ? { i: id } : {}),
...(key !== undefined ? { k: key } : {}),
c: renderedChildren,
...(hasProps ? { p: transformedProps } : {}),
}
Expand All @@ -283,6 +306,7 @@ function renderNodeInternal(element: ReactNode, context: VoltraRenderingContext)
const voltraHostElement: VoltraElementJson = {
t: getComponentId(child.type),
...(id ? { i: id } : {}),
...(key !== undefined ? { k: key } : {}),
...(hasChildren ? { c: renderedChildren } : {}),
...(hasProps ? { p: transformedProps } : {}),
}
Expand Down
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export type VoltraPropValue = string | number | boolean | null | VoltraNodeJson
export type VoltraElementJson = {
t: number
i?: string
k?: string
c?: VoltraNodeJson
p?: Record<string, VoltraPropValue>
}
Expand Down
Loading