Editorial cover graphic with a branching line motif in gold on cream.

Why my SDC props kept disappearing three components deep

A cleaner way to compose Single Directory Components across boundaries, and what it took to make it installable by a stranger.

The friction

A card component renders a media component, which renders a caption component. Set variant: 'feature' on the card, and by the time you’re looking at the caption three levels down, it’s back to default. Nothing crashed. Nothing warned you. The caption just quietly rendered as if the outer component had never made a decision.

That’s the shape of the bug that kept showing up once a component library got past its first two or three levels of nesting: a prop set once at the top, silently absent by the bottom, and no error to point at where it dropped.

Why what already existed did not fit

Single Directory Components scope their props deliberately. Each component.yml declares exactly what it accepts, and Twig only passes along what a parent explicitly hands to {{ include_component() }}. That’s the right default. A component that silently inherited its entire calling context would be impossible to reason about in isolation, which is the whole point of SDC.

But “explicit” and “re-declared at every level” turned out to be different things. The workaround was passing variant: variant down through every intermediate component in the tree, which works until the tree gets a fourth level, or a fifth, and someone building a new nested component forgets one hop. Nothing about SDC’s contract makes that mistake visible. The component still renders, just with the wrong default, and it reads as a styling bug rather than a plumbing one.

What got built

sdc_prop_inherit adds one declaration to component.yml:

components/caption/caption.component.yml
props:
type: object
properties:
variant:
type: string
inherit: true
enum: [default, feature, compact]
default: default

A prop marked inherit: true resolves against the render tree’s ancestor context if the calling component didn’t pass it explicitly. It’s not scope-widening: a component still has to declare the prop to receive it, and an explicit value at any level still wins over an inherited one. It closes exactly the gap where the same prop, same value, needs to survive an unknown number of intermediate components without every one of them having to know about it.

  1. Pagevariant="feature"
  2. Cardvariant = undefined
  3. Mediavariant = undefined
  4. Captionvariant = undefined

Only the level that set the prop can see it — the caption renders unstyled, and nothing errors.

Checking the community

Before generalising a client fix into a module, the move is always to search the issue queue for other people hitting the same wall in different words. This one turned up quickly. Someone had opened an issue asking whether SDC’s core maintainers considered prop inheritance in scope for the base plugin, and the answer was a reasonable no: SDC’s job is defining a component’s contract, not deciding how a whole tree of components shares state. That’s the right boundary for core to hold, and it’s exactly the shape of problem a contrib module exists to solve instead.

Generalising it

The version that worked on one build assumed a fixed set of prop names, because that’s all the client project needed. Making it installable by a stranger meant the inheritance behaviour had to live in the prop’s own schema, as inherit: true, rather than in a hardcoded list somewhere in the module. It also meant handling the case where a prop is inherit: true at one level and explicitly overridden with a different enum value two levels further down: the override has to win locally without breaking inheritance for every sibling that didn’t override it. That took an actual override-vs-inherited resolution order in the plugin manager, not the naive “just copy the value down” version I started with.

How to use it

Enable the module, mark a prop inherit: true in any component’s schema, and stop passing it explicitly through components that only exist to lay out other components. The component still declares the prop, so nothing about the contract at each boundary changes. It just no longer has to be told the same value it would have inherited anyway.

What is unresolved

It doesn’t yet do the right thing inside a loop. If a caption component sits inside a {% for %} that’s rendering ten media items, each iteration needs its own resolved context, and right now the module resolves inheritance once per render tree rather than once per loop iteration, which means a variant override on item four can leak into item five if you’re not careful about where you set it. I have a rough idea of what the fix looks like: resolving per Twig scope rather than per component invocation. I haven’t proven it doesn’t reintroduce the exact opacity this module was built to avoid. If you’ve solved that part, the issue queue is open.