@@ -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 /**
@@ -257,8 +281,14 @@ private function outerBlockParamsExpr(): string
257281
258282 /**
259283 * Compile a block program, pushing/popping its block params around the compilation.
260- * Returns a PHP closure string: the signature varies based on whether the program declares or
261- * inherits block params, and a $sc preamble is added when depths are accessed multiple times.
284+ * Returns either a hoisted variable reference (e.g. '$p0') or an inline closure string.
285+ *
286+ * Closures that do not capture any runtime variable ($declaresBp receive $blockParams as a
287+ * parameter; default programs have no block-param dependency) are hoisted to eval-scope
288+ * variables so they are created once at template() time and reused across render calls.
289+ *
290+ * The one non-hoistable case is $inheritsBp && !$declaresBp: the closure must capture
291+ * $blockParams from its enclosing runtime scope via use(), so it must stay inline.
262292 */
263293 private function compileProgramWithBlockParams (Program $ program ): string
264294 {
@@ -283,6 +313,26 @@ private function compileProgramWithBlockParams(Program $program): string
283313 $ inheritsBp => "function( \$cx, \$in) use ( \$blockParams) " ,
284314 default => "function( \$cx, \$in) " ,
285315 };
316+ // Hoist closures that capture no runtime variable. The use($blockParams) case
317+ // ($inheritsBp && !$declaresBp) must remain inline; all others are safe to hoist.
318+ if ($ this ->isHoistingEnabled && !($ inheritsBp && !$ declaresBp )) {
319+ $ varName = '$p ' . $ this ->hoistedProgramIdx ++;
320+ // Inner programs compile before outer ones (depth-first), so any $pN referenced in
321+ // this body was already assigned a lower index and will be emitted first. Add a
322+ // use() clause so the hoisted closure can access those sibling variables.
323+ preg_match_all ('/\$p\d+/ ' , $ preamble . $ body , $ matches );
324+ $ refs = array_unique ($ matches [0 ]);
325+ $ useClause = $ refs ? ' use ( ' . implode (', ' , $ refs ) . ') ' : '' ;
326+ $ this ->hoistedPrograms [$ varName ] = "$ sig$ useClause { {$ preamble }return $ body;} " ;
327+ return $ varName ;
328+ }
329+
330+ if ($ inheritsBp && !$ declaresBp ) {
331+ // Non-hoistable: use($blockParams) captures a runtime value, so the closure must stay
332+ // inline. Still extend its use() clause with any hoisted $pN it references.
333+ $ extendedUse = $ this ->extendUseVarsWithHoistedRefs ('$blockParams ' , $ preamble . $ body );
334+ return "function( \$cx, \$in) use ( $ extendedUse) { {$ preamble }return $ body;} " ;
335+ }
286336 return "$ sig { {$ preamble }return $ body;} " ;
287337 }
288338
@@ -402,7 +452,8 @@ private function DecoratorBlock(BlockStatement $block): string
402452 $ this ->context ->usedPartial [$ partialName ] = '' ;
403453
404454 // Capture $blockParams if we're inside a block-param scope so the inline partial body can access them.
405- $ useVars = $ this ->blockParamsUseVars ();
455+ // Also capture any hoisted $pN programs referenced in the body.
456+ $ useVars = $ this ->extendUseVarsWithHoistedRefs ($ this ->blockParamsUseVars (), $ body );
406457 $ escapedName = self ::quote ($ partialName );
407458 return self ::getRuntimeFunc ('in ' , "\$cx, $ escapedName, " . self ::templateClosure ($ body , useVars: $ useVars ));
408459 }
@@ -480,7 +531,8 @@ private function PartialBlockStatement(PartialBlockStatement $statement): string
480531 $ vars = $ this ->compilePartialParams ($ statement ->params , $ statement ->hash );
481532
482533 // Capture $blockParams if we're inside a block-param scope so the partial block body can access them.
483- $ useVars = $ this ->blockParamsUseVars ();
534+ // Also capture any hoisted $pN programs referenced in the body.
535+ $ useVars = $ this ->extendUseVarsWithHoistedRefs ($ this ->blockParamsUseVars (), $ body );
484536 $ bodyClosure = self ::templateClosure ($ body , useVars: $ useVars );
485537
486538 if ($ partialName !== null && !$ found ) {
@@ -924,6 +976,24 @@ private function throwKnownHelpersOnly(string $helperName): never
924976 throw new \Exception ("You specified knownHelpersOnly, but used the unknown helper $ helperName " );
925977 }
926978
979+ /**
980+ * Extends $useVars with any $pN hoisted program references found in $code.
981+ * Called before templateClosure() and for the non-hoistable use($blockParams) closure case,
982+ * so that all closures can see the hoisted program variables they reference.
983+ */
984+ private function extendUseVarsWithHoistedRefs (string $ useVars , string $ code ): string
985+ {
986+ if (!$ this ->hoistedPrograms ) {
987+ return $ useVars ;
988+ }
989+ preg_match_all ('/\$p\d+/ ' , $ code , $ matches );
990+ $ refs = array_unique ($ matches [0 ]);
991+ if (!$ refs ) {
992+ return $ useVars ;
993+ }
994+ return $ useVars ? $ useVars . ', ' . implode (', ' , $ refs ) : implode (', ' , $ refs );
995+ }
996+
927997 /**
928998 * Build an hbch (known) or dynhbch (unknown) inline helper call string.
929999 * @param Expression[] $params
0 commit comments