@@ -37,6 +37,16 @@ final class Compiler
3737 */
3838 private array $ blockParamValues = [];
3939
40+ /**
41+ * Hoisted block program closures: varName => closure string.
42+ * Populated during main-template compilation; emitted before the render closure in composePHPRender
43+ * so they are created once per template() call and reused across all render calls.
44+ * Only populated when $isHoistingEnabled is true (main compiler instance, not partial compilers).
45+ * @var array<string, string>
46+ */
47+ private array $ hoistedPrograms = [];
48+ private int $ hoistedProgramIdx = 0 ;
49+
4050 /**
4151 * Stack of booleans, one per active compileProgram() call.
4252 * Each entry is set to true if that invocation directly emitted a $blockParams reference.
@@ -58,8 +68,13 @@ final class Compiler
5868 */
5969 private bool $ compilingHelperArgs = false ;
6070
71+ /**
72+ * @param bool $isHoistingEnabled Only true for the main-template Compiler. Partial compilers use the default
73+ * so their block programs remain inline (partial closures are recreated per render call anyway).
74+ */
6175 public function __construct (
6276 private readonly Parser $ parser ,
77+ private readonly bool $ isHoistingEnabled = false ,
6378 ) {}
6479
6580 public function compile (Program $ program , Context $ context ): string
@@ -68,6 +83,8 @@ public function compile(Program $program, Context $context): string
6883 $ this ->blockParamValues = [];
6984 $ this ->bpRefStack = [];
7085 $ this ->lastCompileProgramHadDirectBpRef = false ;
86+ $ this ->hoistedPrograms = [];
87+ $ this ->hoistedProgramIdx = 0 ;
7188 return $ this ->compileProgram ($ program );
7289 }
7390
@@ -79,8 +96,15 @@ public function compile(Program $program, Context $context): string
7996 public function composePHPRender (string $ code ): string
8097 {
8198 $ partials = implode (", \n" , $ this ->context ->partialCode );
82- $ closure = self ::templateClosure ($ code , $ partials , "\n \$in = & \$cx->data['root']; " );
83- return "use " . Runtime::class . " as LR; \nreturn $ closure; " ;
99+ $ useVars = $ this ->hoistedPrograms ? implode (', ' , array_keys ($ this ->hoistedPrograms )) : '' ;
100+ $ closure = self ::templateClosure ($ code , $ partials , "\n \$in = & \$cx->data['root']; " , $ useVars );
101+
102+ $ assignments = '' ;
103+ foreach ($ this ->hoistedPrograms as $ var => $ closureStr ) {
104+ $ assignments .= "$ var = $ closureStr; \n" ;
105+ }
106+
107+ return "use " . Runtime::class . " as LR; \n{$ assignments }return $ closure; " ;
84108 }
85109
86110 /**
@@ -258,8 +282,14 @@ private function outerBlockParamsExpr(): string
258282
259283 /**
260284 * Compile a block program, pushing/popping its block params around the compilation.
261- * Returns a PHP closure string: the signature varies based on whether the program declares or
262- * inherits block params, and a $sc preamble is added when depths are accessed multiple times.
285+ * Returns either a hoisted variable reference (e.g. '$p0') or an inline closure string.
286+ *
287+ * Closures that do not capture any runtime variable ($declaresBp receive $blockParams as a
288+ * parameter; default programs have no block-param dependency) are hoisted to eval-scope
289+ * variables so they are created once at template() time and reused across render calls.
290+ *
291+ * The one non-hoistable case is $inheritsBp && !$declaresBp: the closure must capture
292+ * $blockParams from its enclosing runtime scope via use(), so it must stay inline.
263293 */
264294 private function compileProgramWithBlockParams (Program $ program ): string
265295 {
@@ -284,6 +314,26 @@ private function compileProgramWithBlockParams(Program $program): string
284314 $ inheritsBp => "function( \$cx, \$in) use ( \$blockParams) " ,
285315 default => "function( \$cx, \$in) " ,
286316 };
317+ // Hoist closures that capture no runtime variable. The use($blockParams) case
318+ // ($inheritsBp && !$declaresBp) must remain inline; all others are safe to hoist.
319+ if ($ this ->isHoistingEnabled && !($ inheritsBp && !$ declaresBp )) {
320+ $ varName = '$p ' . $ this ->hoistedProgramIdx ++;
321+ // Inner programs compile before outer ones (depth-first), so any $pN referenced in
322+ // this body was already assigned a lower index and will be emitted first. Add a
323+ // use() clause so the hoisted closure can access those sibling variables.
324+ preg_match_all ('/\$p\d+/ ' , $ preamble . $ body , $ matches );
325+ $ refs = array_unique ($ matches [0 ]);
326+ $ useClause = $ refs ? ' use ( ' . implode (', ' , $ refs ) . ') ' : '' ;
327+ $ this ->hoistedPrograms [$ varName ] = "$ sig$ useClause { {$ preamble }return $ body;} " ;
328+ return $ varName ;
329+ }
330+
331+ if ($ inheritsBp && !$ declaresBp ) {
332+ // Non-hoistable: use($blockParams) captures a runtime value, so the closure must stay
333+ // inline. Still extend its use() clause with any hoisted $pN it references.
334+ $ extendedUse = $ this ->extendUseVarsWithHoistedRefs ('$blockParams ' , $ preamble . $ body );
335+ return "function( \$cx, \$in) use ( $ extendedUse) { {$ preamble }return $ body;} " ;
336+ }
287337 return "$ sig { {$ preamble }return $ body;} " ;
288338 }
289339
@@ -403,7 +453,8 @@ private function DecoratorBlock(BlockStatement $block): string
403453 $ this ->context ->usedPartial [$ partialName ] = '' ;
404454
405455 // Capture $blockParams if we're inside a block-param scope so the inline partial body can access them.
406- $ useVars = $ this ->blockParamsUseVars ();
456+ // Also capture any hoisted $pN programs referenced in the body.
457+ $ useVars = $ this ->extendUseVarsWithHoistedRefs ($ this ->blockParamsUseVars (), $ body );
407458 $ escapedName = self ::quote ($ partialName );
408459 return self ::getRuntimeFunc ('in ' , "\$cx, $ escapedName, " . self ::templateClosure ($ body , useVars: $ useVars ));
409460 }
@@ -481,7 +532,8 @@ private function PartialBlockStatement(PartialBlockStatement $statement): string
481532 $ vars = $ this ->compilePartialParams ($ statement ->params , $ statement ->hash );
482533
483534 // Capture $blockParams if we're inside a block-param scope so the partial block body can access them.
484- $ useVars = $ this ->blockParamsUseVars ();
535+ // Also capture any hoisted $pN programs referenced in the body.
536+ $ useVars = $ this ->extendUseVarsWithHoistedRefs ($ this ->blockParamsUseVars (), $ body );
485537 $ bodyClosure = self ::templateClosure ($ body , useVars: $ useVars );
486538
487539 if ($ partialName !== null && !$ found ) {
@@ -925,6 +977,24 @@ private function throwKnownHelpersOnly(string $helperName): never
925977 throw new \Exception ("You specified knownHelpersOnly, but used the unknown helper $ helperName " );
926978 }
927979
980+ /**
981+ * Extends $useVars with any $pN hoisted program references found in $code.
982+ * Called before templateClosure() and for the non-hoistable use($blockParams) closure case,
983+ * so that all closures can see the hoisted program variables they reference.
984+ */
985+ private function extendUseVarsWithHoistedRefs (string $ useVars , string $ code ): string
986+ {
987+ if (!$ this ->hoistedPrograms ) {
988+ return $ useVars ;
989+ }
990+ preg_match_all ('/\$p\d+/ ' , $ code , $ matches );
991+ $ refs = array_unique ($ matches [0 ]);
992+ if (!$ refs ) {
993+ return $ useVars ;
994+ }
995+ return $ useVars ? $ useVars . ', ' . implode (', ' , $ refs ) : implode (', ' , $ refs );
996+ }
997+
928998 /**
929999 * Build an hbch (known) or dynhbch (unknown) inline helper call string.
9301000 * @param Expression[] $params
0 commit comments