Template runtime and block composition - #4917
Conversation
e8e4cea to
5ba9acb
Compare
|
@upsun-dispatch review |
There was a problem hiding this comment.
Warning
Changes suggested — 🟡 1 warning · 🔵 1 minor point · ⚪ 1 nitpick
🔍 Full review · 19 files reviewed
⚪ Nitpick
src/BlockChain.php:84—streamBlock()is a generator, sogetBlock()— and therefore theRuntimeErrorfor an unknown block name — only runs on first iteration, not when the method is called.$chain->streamBlock('missing')returns normally and only throws once the caller starts consuming it (confirmed by running it), unlikerenderBlock()/displayBlock()which fail immediately.
Verification
- Chain composition is first-wins in template order including each template's parents — verified block-name order and that
sharedresolves toparent1, nottheme2. - Freezing does not mutate the loaded template: after building a chain with
parent=parent1,$twig->load('theme')->renderBlock('field', ['parent'=>'parent2'])still renderstwo. - The clone rebinds only blocks owned by the original, so
use-provided trait blocks keep their ownparent()lineage (aliased trait block renderstrait/base). - The new block/macro prologue falls back to
$this->macroswhenmacroImportSourceis null, leaving normal (unfrozen) templates unchanged. - Circular inheritance is caught by
beginFreezeand raises the LogicException naming the entry template instead of recursing.
The diff adds tests/BlockChainTest.php (636 lines, most cases run in both yield modes) and a TemplateTest case for the renderParentBlock buffer unwinding, plus updated Node compile-output expectations; the tests and tests-phpunit-11 CI jobs run PHPUnit on PHP 8.1-8.5 (with a use_yield => true variant on 8.2) and a phpstan job covers src. No test covers exceptions escaping BlockChain construction unwrapped, nor streamBlock's missing globals beyond asserting the current asymmetry.
Review details
- Commit: 5ba9acb
- Model: claude-opus-5
| throw new \TypeError(\sprintf('Block chain templates must be strings or "%s" instances, "%s" given.', TemplateWrapper::class, get_debug_type($template))); | ||
| } | ||
|
|
||
| $current = $template->unwrap()->freezeLineage($resolution); |
There was a problem hiding this comment.
🟡 Warning — Chain construction loses template/line context and bypasses Twig's error contract.
Parent resolution during construction runs user expressions ({% extends parent|filter %}, dynamic parent names) outside of any Twig error handling, so a non-Twig\Error\Error exception escapes new BlockChain(...) raw. Verified on this checkout: with 'theme' => '{% extends parent|boom %}...' where boom throws, $twig->render('theme') yields Twig\Error\RuntimeError: An exception has been thrown during the rendering of a template ("kaboom") in "theme" at line 1., while new BlockChain($twig, ['theme'], ['parent' => 'parent']) propagates a bare DomainException: kaboom with no template name or line. The same happens for a dynamic parent expression that evaluates to null (Template::load(): Argument #1 ... null given TypeError escapes construction, where rendering reports it as a RuntimeError in "theme" at line 1). Callers that catch Twig\Error\Error around chain construction will not catch these, and the failing template is not identified.
| */ | ||
| public function streamBlock(string $name, array $context = []): iterable | ||
| { | ||
| yield from $this->getBlock($name)->yieldBlock($name, $context, $this->blocks); |
There was a problem hiding this comment.
🔵 Minor — Silent output difference between the class's own render and stream paths.
streamBlock() passes the caller context through unchanged while renderBlock() and displayBlock() merge $this->env->getGlobals(). The same block rendered through the three entry points therefore sees different variables: with a global global => 'GLOBAL', renderBlock('field', ['local' => 'L']) produces L:GLOBAL but streaming the same block produces L:none (and raises an undefined-variable error under strict_variables). Switching from renderBlock() to streamBlock() for memory reasons silently changes output.
This PR addresses the Symfony compatibility break from #4910
It introduces runtime composition of templates used as collections of named block renderers: The renderer provides an ordered set of unrelated templates. The first matching block wins, nested
block()calls see the complete composed set, andparent()remains within the block’s own inheritance orusehierarchy.This feature is going to be useful for more than just Symfony.
Strong non-Symfony use cases
Ibexa Core
Project:
ibexa/coreFeature: CMS field rendering through
FieldBlockRendererIbexa maintains prioritized field templates, selects blocks such as
ibexa_string_field, walks parent templates, constructs a block map and passes it torenderBlock().This is the strongest independent fit for
BlockChain:Data-grid and listing renderers
The audit found the same broad mechanism in:
Prezent/prezent-grid,src/Twig/GridRenderer.phppawellen/listing,Renderer/ListingRenderer.phpBraunstetter/data-grid-bundle,src/GridRendererEngine.phpAnoDataGrid,DataGridExtension.phpTheir common feature is layered grid themes:
Several accessed
unwrap(),getBlocks()orgetParent()directly; others passed manually assembled block maps intorenderBlock()ordisplayBlock().Adjacent use cases
The audit also found block-library patterns that could benefit if they grow into multi-template composition:
login_input,login_submit,login_form_footerandlogin_links; independently rendersbody,script,ready_scriptandcss.subject,body_textandbody_htmlblocks.toolbar,field_text,dashboard_metricorlogin_footer.Important negative finding
Shopware-style plugin inheritance, and similar Drupal or Sylius layering, are not considered a direct fit. Those systems expect
parent()to call the next plugin override.BlockChaindeliberately keepsparent()inside the defining template’s normal lineage.