
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:
props: type: object properties: variant: type: string inherit: true enum: [default, feature, compact] default: defaultA 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.
Pagevariant="feature"Cardvariant = undefinedMediavariant = undefinedCaptionvariant = 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.