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
34 changes: 26 additions & 8 deletions packages/fiber/src/core/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -255,19 +255,32 @@ export function prepare<T = any>(target: T, root: RootStore, type: string, props
}

export function resolve(root: any, key: string): { root: any; key: string; target: any } {
let target: unknown = root[key]
if (!key.includes('-')) return { root, key, target }
if (!key.includes('-')) return { root, key, target: root[key] }

// Resolve pierced target
target = root
for (const part of key.split('-')) {
// First try the entire key as a single property (e.g., 'foo-bar')
if (key in root) {
return { root, key, target: root[key] }
}

// Try piercing (e.g., 'material-color' -> material.color)
let target = root
const parts = key.split('-')

for (const part of parts) {
if (typeof target !== 'object' || target === null) {
if (target !== undefined) {
// Property exists but has unexpected shape
const remaining = parts.slice(parts.indexOf(part)).join('-')
return { root: target, key: remaining, target: undefined }
}
// Property doesn't exist - fallback to original key
return { root, key, target: undefined }
}
key = part
root = target
target = (target as any)?.[key]
target = target[key]
}

// TODO: change key to 'foo-bar' if target is undefined?

return { root, key, target }
}

Expand Down Expand Up @@ -411,6 +424,11 @@ export function applyProps<T = any>(object: Instance<T>['object'], props: Instan

let { root, key, target } = resolve(object, prop)

// Throw an error if we attempted to set a pierced prop to a non-object
if (target === undefined && (typeof root !== 'object' || root === null)) {
throw Error(`R3F: Cannot set "${prop}". Ensure it is an object before setting "${key}".`)
}

// Layers must be written to the mask property
if (target instanceof THREE.Layers && value instanceof THREE.Layers) {
target.mask = value.mask
Expand Down
70 changes: 70 additions & 0 deletions packages/fiber/tests/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,63 @@ describe('resolve', () => {
expect(key).toBe('bar')
expect(target).toBe(root[key])
})

it('should prioritize direct property over piercing', () => {
const object = {
'foo-bar': 'direct',
foo: { bar: 'pierced' },
}
const { root, key, target } = resolve(object, 'foo-bar')

expect(root).toBe(object)
expect(key).toBe('foo-bar')
expect(target).toBe('direct')
})

it('should handle undefined direct property values', () => {
const object = { 'foo-bar': undefined }
const { root, key, target } = resolve(object, 'foo-bar')

expect(root).toBe(object)
expect(key).toBe('foo-bar')
expect(target).toBe(undefined)
})

it('should handle null direct property values', () => {
const object = { 'foo-bar': null }
const { root, key, target } = resolve(object, 'foo-bar')

expect(root).toBe(object)
expect(key).toBe('foo-bar')
expect(target).toBe(null)
})

it('should return non-object as root when piercing fails due to non-object intermediate', () => {
const object = { foo: 'not-an-object' }
const { root, key, target } = resolve(object, 'foo-bar')

expect(root).toBe('not-an-object')
expect(key).toBe('bar')
expect(target).toBe(undefined)
})

it('should return null as root when piercing fails due to null intermediate', () => {
const object = { foo: null }
const { root, key, target } = resolve(object, 'foo-bar')

expect(root).toBe(null)
expect(key).toBe('bar')
expect(target).toBe(undefined)
})

it('should handle non-dashed keys normally', () => {
const object = { foo: 'value' }
const { root, key, target } = resolve(object, 'foo')

expect(root).toBe(object)
expect(key).toBe('foo')
expect(target).toBe('value')
})
})

describe('attach / detach', () => {
Expand Down Expand Up @@ -311,6 +368,19 @@ describe('applyProps', () => {
expect(() => applyProps(target, {})).not.toThrow()
})

it('should not throw when applying unknown props', () => {
const target = new THREE.Object3D()
applyProps(target, {})
expect(() => applyProps(target, { ['foo-bar']: 1 })).not.toThrow()
expect((target as any)['foo-bar']).toBe(undefined)
})

it('should throw when applying unknown props due to non-object intermediate', () => {
const target = new THREE.Object3D()
applyProps(target, { foo: 1 })
expect(() => applyProps(target, { ['foo-bar']: 1 })).toThrow()
})

it('should filter reserved props without accessing them', () => {
const get = jest.fn()
const set = jest.fn()
Expand Down