Why sdc_prop_inherit refuses to work without a Twig template

A design constraint in SDC Property Inheritance, argued in public: why inheritance hooks into the template, not the component definition.

Someone filed an issue on sdc_prop_inherit (SDC Property Inheritance) this week asking whether the module could support a component that ships no Twig template of its own. It is a fair question, it is the kind of thing that turns up once a module escapes the situation it was written for, and the honest answer is no.

I do not want to fix it either. That is a slightly awkward thing to say about your own module in a public queue, which is why it is worth writing down properly rather than leaving as a terse comment on a Tuesday.

Where inheritance actually resolves

The whole premise of the module is that a value handed to a component should be reachable by the components it renders inside itself, without every intermediate layer having to declare and forward it. A card sits inside a grid sits inside a section, and the section knows something — a theme variant, a heading level, a density setting — that the card needs and the grid does not care about.

The important word there is renders. A component’s props are not a static fact about the component. They are a fact about one particular render of it, in one particular place in one particular tree. The section only knows it is dark-on-light because of what was passed to it a few microseconds ago. Inheritance is a question about the tree, and the tree only exists while it is being built.

In SDC, the place where that tree is walked is the Twig template. That is where {% embed %} and {% include %} cross a component boundary, where slots resolve, where the render pipeline has actually descended one level. So the module hooks the template, because the template is the boundary.

{# section/section.twig — the value enters here #}
{% embed 'mytheme:grid' with { columns: 3 } %}
{% block items %}
{% include 'mytheme:card' with { title: item.title } %}
{% endblock %}
{% endembed %}

grid never mentions the section’s variant. card receives it anyway. That only works because there is a rendering moment at each hop where something can be asked about its ancestors. Take the template away and there is no hop — the component is a definition and a set of props, and nothing in between them knows what contained it.

The strongest version of the other argument

The counter-position is real, and I want to state it at full strength rather than a version I can knock down.

Inheritance could be declared in *.component.yml instead. Something like an inherits: key alongside props, resolved by the plugin manager when component definitions are built. That would be declarative, statically analysable, visible to anyone reading the component directory without opening a template, checkable against the prop schema before anything renders, and — the point of the issue — completely indifferent to whether the component has a template. A definition-level mechanism works for every component, including the ones that only exist to be composed by something else.

That is a genuinely better developer experience on the axis of discoverability. A prop that arrives from nowhere is worse than a prop declared in a file you were going to read anyway. If the argument were only about ergonomics, definition-level inheritance wins.

It is not only about ergonomics.

The branch I threw away

I did build it, because I did not trust my own reasoning until I had watched it fail.

The first attempt resolved inheritance while component definitions were being assembled. That died immediately and instructively: definitions are built once and cached, and the value I wanted was not a property of the definition at all. I was trying to bake a runtime answer into a compile-time artefact. There is no version of that which is correct — the same component under two different parents needs two different answers, and a cached definition has room for one.

The second attempt was better. Resolve during the pre-render of the component element, attach the inherited values to the props array, let Twig receive them as ordinary variables. That worked. It rendered the right output. I kept it for about two days.

What killed it was sitting down with the output and trying to answer a simple question: where did this value come from? In the template-hooked version, the answer is in the template — the boundary is written down, in the file where the composition happens, next to the {% embed %} that caused it. In the pre-render version, the child template shows a variable, the parent template shows nothing, and the only record of the relationship lives in a callback that ran between them. Twig’s debug output could not tell me. Reading either component in isolation could not tell me. I had a working feature and no way to explain it to someone who had not written it.

Debuggability is a durability problem

This is where the constraint stops being an implementation detail and starts being a position.

A prop that appears in a component without an explanation is a prop that the next developer cannot safely remove. Multiply that across a component library that four teams contribute to and a platform that outlives the people who built it, and you get the specific failure mode I care most about: a system that works, that nobody understands, and that therefore nobody will touch. Every value in a component tree should have a traceable origin, and the cheapest place to make that origin legible is the file where the boundary is actually crossed.

So the requirement of a Twig template is not a limitation the module has yet to overcome. It is the module declining to resolve props anywhere that a human cannot inspect.

What would change my mind

Two things, specifically.

One: if core SDC grows a documented render-time extension point that is not the template — somewhere inherited values can be attached with provenance, so the origin survives into debug output rather than evaporating into a callback. The objection is to invisible resolution, not to non-Twig resolution. Give me a place to write down which ancestor supplied a value, and the template stops being load-bearing.

Two: if the template-less pattern turns out to be common enough in contrib that this constraint blocks real work. I have assumed it is a corner. That assumption is worth testing, and the issue is the right place to test it — cases beat my intuition.

What I am sketching next is narrower than the full counter-proposal, and I would rather argue about the narrow version. A definition-level opt-in that handles only static defaults — values knowable without a parent, declared in the component YAML, never permitted to override anything an ancestor passed at render time, and emitted into the render array with a marker saying where they came from. Static defaults are the half of the problem that genuinely does not need a tree. It would not solve inheritance for template-less components. It would solve the part of it that was never really inheritance.

The open question I have not answered: if a component has no template of its own and something else renders it, is that boundary observable from anywhere? Not conceptually — concretely, in the render array, in a way a maintainer could read three years from now. If you have solved that, or you have a template-less component pattern that makes this constraint hurt, put it in the issue. I will take the case over the theory.