Skip to content

Commit 66726f7

Browse files
committed
fix: draw a placeholder for missing or cyclic composite elements
A composite whose definition is missing (connection disabled or element removed) now draws a 'Missing Element' placeholder instead of nothing. Track the composite types being expanded, so a composite that contains itself draws an '∞' placeholder instead of recursing forever.
1 parent 8e61744 commit 66726f7

6 files changed

Lines changed: 160 additions & 113 deletions

File tree

‎companion/lib/Graphics/ConvertGraphicsElements.ts‎

Lines changed: 43 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,8 @@ export async function ConvertSomeButtonGraphicsElementForDrawing(
134134
globalReferences,
135135
processedElementIds,
136136
currentLocationStr,
137-
getRenderAtLocation
137+
getRenderAtLocation,
138+
new Set()
138139
)
139140

140141
const newElements = await convertElements(context, elements, '')
@@ -224,8 +225,11 @@ async function convertElements(
224225
if (cacheEntry.referencedLocation)
225226
context.globalReferences.referencedLocations.drawn.add(cacheEntry.referencedLocation)
226227

227-
// If this is a group, populate the children
228-
if (element.type === 'group' || element.type === 'composite') {
228+
if (cacheEntry.compositeElement?.isPlaceholder) {
229+
// Placeholder already carries its children
230+
return cacheEntry.drawElement
231+
} else if (element.type === 'group' || element.type === 'composite') {
232+
// If this is a group, populate the children
229233
const childIdPrefix = cacheEntry.compositeElement ? cacheEntry.compositeElement.childIdPrefix : idPrefix
230234

231235
const children =
@@ -234,8 +238,11 @@ async function convertElements(
234238
: (context.resolveCompositeElement(element.connectionId, element.elementId)?.elements ?? [])
235239

236240
if (cacheEntry.drawElement?.type === 'group') {
237-
const childContext = cacheEntry.compositeElement?.childPropOverrides
238-
? context.withPropOverrides(cacheEntry.compositeElement.childPropOverrides)
241+
const childContext = cacheEntry.compositeElement
242+
? context.withCompositeChild(
243+
cacheEntry.compositeElement.elementId,
244+
cacheEntry.compositeElement.childPropOverrides
245+
)
239246
: context
240247

241248
// Recurse to process children
@@ -577,64 +584,54 @@ async function convertCompositeElementForDrawing(
577584
// The full ID of this composite element (with any parent prefixes)
578585
const compositeFullId = idPrefix + element.id
579586

587+
// Perform enabled check first, to avoid executing expressions when not needed
588+
const enabled = helper.getBoolean('enabled', true)
589+
580590
const childElement = context.resolveCompositeElement(element.connectionId, element.elementId)
581-
if (!childElement) {
582-
return {
583-
drawElement: null,
584-
references,
585-
compositeElement: {
586-
elementId: compositeElementId,
587-
childIdPrefix: compositeFullId + '/',
588-
childPropOverrides: {},
589-
},
590-
}
591+
const placeholderText = !childElement
592+
? 'Missing\nElement'
593+
: context.compositeAncestors.has(compositeElementId)
594+
? '\u221e'
595+
: null
596+
597+
let propOverrides: VariableValues = {}
598+
let childIdPrefix = compositeFullId + '/'
599+
if (childElement && placeholderText === null) {
600+
propOverrides = parseCompositeElementChildOptions(helper, childElement, element)
601+
const propOverridesHash = createHash('sha256').update(JSON.stringify(propOverrides)).digest('hex')
602+
childIdPrefix = compositeFullId + '-' + propOverridesHash + '/'
591603
}
592604

593-
const propOverrides = parseCompositeElementChildOptions(helper, childElement, element)
594-
const propOverridesHash = createHash('sha256').update(JSON.stringify(propOverrides)).digest('hex')
595-
596-
const childIdPrefix = compositeFullId + '-' + propOverridesHash + '/'
597-
598-
// Perform enabled check first, to avoid executing expressions when not needed
599-
const enabled = helper.getBoolean('enabled', true)
600-
if (!enabled && context.onlyEnabled)
601-
return {
602-
drawElement: null,
603-
references,
604-
compositeElement: {
605-
elementId: compositeElementId,
606-
childIdPrefix,
607-
childPropOverrides: propOverrides,
608-
},
609-
}
605+
const compositeElement: ElementConversionCacheEntry['compositeElement'] = {
606+
elementId: compositeElementId,
607+
childIdPrefix,
608+
childPropOverrides: propOverrides,
609+
isPlaceholder: placeholderText !== null,
610+
}
610611

611-
const opacity = helper.getNumber('opacity', 1, 0.01)
612-
const bounds = convertDrawBounds(helper)
612+
if (!enabled && context.onlyEnabled) return { drawElement: null, references, compositeElement }
613613

614614
// Note: Composite elements render as groups - hash is shallow (children have their own hashes)
615615
const drawElement: ButtonGraphicsGroupDrawElement = {
616616
id: compositeFullId,
617617
type: 'group',
618618
usage: element.usage,
619619
enabled,
620-
opacity,
621-
...bounds,
620+
opacity: helper.getNumber('opacity', 1, 0.01),
621+
...convertDrawBounds(helper),
622622
rotation: helper.getNumber('rotation', 0),
623623
squareCoords: false,
624-
children: [], // Will be filled in by caller
624+
children: [], // Will be filled in by caller, unless a placeholder
625625
contentHash: '', // Will be computed below
626626
}
627627

628628
drawElement.contentHash = computeElementContentHash(drawElement)
629-
return {
630-
drawElement,
631-
references,
632-
compositeElement: {
633-
elementId: compositeElementId,
634-
childIdPrefix,
635-
childPropOverrides: propOverrides,
636-
},
629+
630+
if (placeholderText !== null) {
631+
drawElement.children = makeReferencePlaceholder(compositeFullId, placeholderText)
637632
}
633+
634+
return { drawElement, references, compositeElement }
638635
}
639636

640637
async function convertImageElementForDrawing(
@@ -930,7 +927,7 @@ function collectChildElementIds(
930927
// If this child is a composite, we need to check if we have a cached entry
931928
// to determine the correct childIdPrefix for its children
932929
if (child.type === 'composite') {
933-
if (cachedEntry?.compositeElement) {
930+
if (cachedEntry?.compositeElement && !cachedEntry.compositeElement.isPlaceholder) {
934931
const compositeChildren = context.resolveCompositeElement(child.connectionId, child.elementId)?.elements
935932
if (compositeChildren) {
936933
collectChildElementIds(context, compositeChildren, cachedEntry.compositeElement.childIdPrefix)

‎companion/lib/Graphics/ConvertGraphicsElements/Helper.ts‎

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -520,11 +520,14 @@ export interface ParseElementsContext {
520520
/** Callback to fetch the last-rendered ImageResult for a given location */
521521
readonly getRenderAtLocation: ((location: ControlLocation) => ImageResult | null) | null
522522

523+
/** Composite element types currently being expanded above this point, used for cycle detection */
524+
readonly compositeAncestors: ReadonlySet<CompositeElementIdString>
525+
523526
/**
524-
* Create a child factory for recursive conversion (e.g., inside composite elements)
525-
* with prop overrides injected into the parser
527+
* Create a child factory for converting the children of a composite element,
528+
* with prop overrides injected into the parser and the composite added to the ancestors
526529
*/
527-
withPropOverrides(propOverrides: VariableValues): ParseElementsContext
530+
withCompositeChild(compositeElementId: CompositeElementIdString, propOverrides: VariableValues): ParseElementsContext
528531

529532
/**
530533
* Resolve a composite element definition by connection and element ID
@@ -548,7 +551,8 @@ export function createParseElementsContext(
548551
globalReferences: ExpressionReferences,
549552
processedElementIds: Set<string>,
550553
currentLocationStr: string | null,
551-
getRenderAtLocation: ((location: ControlLocation) => ImageResult | null) | null
554+
getRenderAtLocation: ((location: ControlLocation) => ImageResult | null) | null,
555+
compositeAncestors: ReadonlySet<CompositeElementIdString>
552556
): ParseElementsContext {
553557
return {
554558
cache,
@@ -558,6 +562,7 @@ export function createParseElementsContext(
558562
drawPixelBuffers,
559563
currentLocationStr,
560564
getRenderAtLocation,
565+
compositeAncestors,
561566

562567
createHelper<T extends { readonly id: string }>(
563568
element: T
@@ -568,7 +573,10 @@ export function createParseElementsContext(
568573
return { helper, references }
569574
},
570575

571-
withPropOverrides(propOverrides: VariableValues): ParseElementsContext {
576+
withCompositeChild(
577+
compositeElementId: CompositeElementIdString,
578+
propOverrides: VariableValues
579+
): ParseElementsContext {
572580
return createParseElementsContext(
573581
compositeElementStore,
574582
parser.createIsolatedChildParser(propOverrides),
@@ -579,7 +587,8 @@ export function createParseElementsContext(
579587
globalReferences,
580588
processedElementIds,
581589
currentLocationStr,
582-
getRenderAtLocation
590+
getRenderAtLocation,
591+
new Set([...compositeAncestors, compositeElementId])
583592
)
584593
},
585594

‎companion/lib/Graphics/ElementConversionCache.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ export interface ElementConversionCacheEntry {
2121
readonly childPropOverrides: VariableValues
2222
/** Prefix applied to child element IDs */
2323
readonly childIdPrefix: string
24+
/** Whether the draw element is a placeholder (missing definition or cycle), with no children to convert */
25+
readonly isPlaceholder: boolean
2426
} | null
2527

2628
/**

‎companion/test/Graphics/ConvertGraphicsElements.test.ts‎

Lines changed: 61 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1687,56 +1687,6 @@ describe('ConvertSomeButtonGraphicsElementForDrawing', () => {
16871687
})
16881688
})
16891689

1690-
test('returns nothing for missing composite definition', async () => {
1691-
const compositeDefinition: CompositeElementDefinition = {
1692-
id: 'composite1',
1693-
name: 'Test Composite',
1694-
sortKey: null,
1695-
description: '',
1696-
options: [],
1697-
elements: [],
1698-
}
1699-
1700-
const instanceDefs = createMockInstanceDefinitions({
1701-
'test-connection': {
1702-
composite1: compositeDefinition,
1703-
},
1704-
})
1705-
1706-
const elements: SomeButtonGraphicsElement[] = [
1707-
{
1708-
id: 'comp1',
1709-
name: '',
1710-
type: 'composite',
1711-
rotation: val(0),
1712-
pinnedProperties: [],
1713-
usage: USAGE,
1714-
enabled: val(true),
1715-
opacity: val(100),
1716-
x: val(0),
1717-
y: val(0),
1718-
width: val(100),
1719-
height: val(100),
1720-
connectionId: 'test-connection',
1721-
elementId: 'nonexistent',
1722-
},
1723-
]
1724-
1725-
const result = await ConvertSomeButtonGraphicsElementForDrawing(
1726-
instanceDefs,
1727-
createMockParser(),
1728-
mockDrawPixelBuffers,
1729-
elements,
1730-
new Map(),
1731-
true,
1732-
null,
1733-
null,
1734-
null
1735-
)
1736-
1737-
expect(result.elements).toHaveLength(0)
1738-
})
1739-
17401690
function makeCompositeEl(elementId: string, overrides: Partial<ButtonGraphicsCompositeElement> = {}) {
17411691
return {
17421692
id: 'comp1',
@@ -1779,6 +1729,12 @@ describe('ConvertSomeButtonGraphicsElementForDrawing', () => {
17791729
)
17801730
}
17811731

1732+
function placeholderText(element: SomeButtonGraphicsDrawElement | undefined): string | undefined {
1733+
const group = element as ButtonGraphicsGroupDrawElement
1734+
const text = group.children.find((c) => c.type === 'text')
1735+
return text?.text
1736+
}
1737+
17821738
test('applies rotation to the group', async () => {
17831739
const instanceDefs = createMockInstanceDefinitions({
17841740
'test-connection': { composite1: makeDefinition('composite1', [makeTextEl({ id: 'child1' })]) },
@@ -1799,6 +1755,61 @@ describe('ConvertSomeButtonGraphicsElementForDrawing', () => {
17991755

18001756
expect(result.elements[0]).toMatchObject({ type: 'group', rotation: 0 })
18011757
})
1758+
1759+
test('draws a placeholder for a missing composite definition', async () => {
1760+
const instanceDefs = createMockInstanceDefinitions({
1761+
'test-connection': { composite1: makeDefinition('composite1', []) },
1762+
})
1763+
1764+
const result = await convert(instanceDefs, [makeCompositeEl('nonexistent')], null)
1765+
1766+
expect(result.elements).toHaveLength(1)
1767+
expect(result.elements[0].type).toBe('group')
1768+
expect(placeholderText(result.elements[0])).toBe('Missing\nElement')
1769+
expect([...result.compositeElements.drawn]).toEqual(['test-connection:nonexistent'])
1770+
})
1771+
1772+
test('draws nothing for a disabled composite with a missing definition', async () => {
1773+
const instanceDefs = createMockInstanceDefinitions({})
1774+
1775+
const result = await convert(instanceDefs, [makeCompositeEl('nonexistent', { enabled: val(false) })], null)
1776+
1777+
expect(result.elements).toHaveLength(0)
1778+
})
1779+
1780+
test('draws a placeholder for a composite that contains itself', async () => {
1781+
const instanceDefs = createMockInstanceDefinitions({
1782+
'test-connection': {
1783+
loop: makeDefinition('loop', [makeTextEl({ id: 'text' }), makeCompositeEl('loop', { id: 'self' })]),
1784+
},
1785+
})
1786+
1787+
const result = await convert(instanceDefs, [makeCompositeEl('loop')], null)
1788+
1789+
const outer = result.elements[0] as ButtonGraphicsGroupDrawElement
1790+
expect(outer.children).toHaveLength(2)
1791+
expect(outer.children[0].type).toBe('text')
1792+
expect(placeholderText(outer.children[1])).toBe('\u221e')
1793+
})
1794+
1795+
test('draws a placeholder for an indirect composite cycle', async () => {
1796+
const instanceDefs = createMockInstanceDefinitions({
1797+
'test-connection': {
1798+
a: makeDefinition('a', [makeCompositeEl('b', { id: 'to-b' })]),
1799+
b: makeDefinition('b', [makeCompositeEl('a', { id: 'to-a' })]),
1800+
},
1801+
})
1802+
1803+
const cache = new ElementConversionCache()
1804+
for (let i = 0; i < 2; i++) {
1805+
// Second pass is served from the cache
1806+
const result = await convert(instanceDefs, [makeCompositeEl('a')], cache)
1807+
1808+
const a = result.elements[0] as ButtonGraphicsGroupDrawElement
1809+
const b = a.children[0] as ButtonGraphicsGroupDrawElement
1810+
expect(placeholderText(b.children[0])).toBe('\u221e')
1811+
}
1812+
})
18021813
})
18031814

18041815
describe('id prefixing', () => {

‎companion/test/Graphics/ConvertGraphicsElements/Helper.test.ts‎

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,8 @@ function makeCtx(
151151
makeGlobalRefs(),
152152
new Set(),
153153
null,
154-
null
154+
null,
155+
new Set()
155156
)
156157
}
157158

@@ -1195,25 +1196,34 @@ describe('createParseElementsContext', () => {
11951196
})
11961197
})
11971198

1198-
describe('withPropOverrides', () => {
1199+
describe('withCompositeChild', () => {
11991200
test('variables injected via propOverrides are resolved in the child context', () => {
12001201
const ctx = makeCtx()
1201-
const child = ctx.withPropOverrides({ 'ns:x': 'injected' })
1202+
const child = ctx.withCompositeChild('conn:elem', { 'ns:x': 'injected' })
12021203
const { helper } = child.createHelper({ id: 'el1', strProp: expr<string>('$(ns:x)') })
12031204
expect(helper.getString('strProp', '')).toBe('injected')
12041205
})
12051206

12061207
test('injected propOverrides do not affect the parent context', () => {
12071208
const ctx = makeCtx()
1208-
ctx.withPropOverrides({ 'ns:x': 'injected' })
1209+
ctx.withCompositeChild('conn:elem', { 'ns:x': 'injected' })
12091210
// Parent parser has no ns:x; missing variable resolves to undefined → defaultValue
12101211
const { helper } = ctx.createHelper({ id: 'el1', strProp: expr<string>('$(ns:x)') })
12111212
expect(helper.getString('strProp', 'default')).toBe('default')
12121213
})
12131214

1215+
test('adds the composite to the ancestors of the child context only', () => {
1216+
const ctx = makeCtx()
1217+
const child = ctx.withCompositeChild('conn:elem', {})
1218+
const grandchild = child.withCompositeChild('conn:other', {})
1219+
expect(ctx.compositeAncestors).toEqual(new Set())
1220+
expect(child.compositeAncestors).toEqual(new Set(['conn:elem']))
1221+
expect(grandchild.compositeAncestors).toEqual(new Set(['conn:elem', 'conn:other']))
1222+
})
1223+
12141224
test('child context is isolated from parent global variable values', () => {
12151225
const ctx = makeCtx({ variableValues: { ns: { existing: 'base' } } })
1216-
const child = ctx.withPropOverrides({ 'ns:injected': 'extra' })
1226+
const child = ctx.withCompositeChild('conn:elem', { 'ns:injected': 'extra' })
12171227
// The child can only see its own propOverrides, not the parent's global variables
12181228
const { helper } = child.createHelper({ id: 'el1', strProp: expr<string>('$(ns:existing)') })
12191229
expect(helper.getString('strProp', 'default')).toBe('default')

0 commit comments

Comments
 (0)