@@ -160,18 +160,17 @@ public static function lookupLength(mixed $base, bool $strict = false): mixed
160160 }
161161
162162 /**
163- * Build a RuntimeContext from raw render options and compile-time partial closures .
163+ * Build a RuntimeContext from raw render options.
164164 *
165165 * @param RenderOptions|array{_cx?: RuntimeContext} $options
166- * @param array<string, Closure> $compiledPartials
167166 */
168- public static function createContext (mixed $ context , array $ options, array $ compiledPartials ): RuntimeContext
167+ public static function createContext (mixed $ context , array $ options ): RuntimeContext
169168 {
170169 $ parentCx = $ options ['_cx ' ] ?? null ;
171170 $ data = $ options ['data ' ] ?? [];
172171
173172 if ($ parentCx !== null ) {
174- // Partial context: reuse the parent's already-merged helpers and partials directly.
173+ // Partial context: reuse the parent's already-merged helpers, partials, and resolver directly.
175174 // PHP copy-on-write ensures inlinePartials is only copied if the partial registers a new {{#* inline}} partial.
176175 // invokePartial() always passes the caller's current data frame via options, so partials inherit @index, @key, etc.
177176 // Unset 'root' first to break the reference established by `$in = &$cx->data['root']` in the
@@ -181,6 +180,7 @@ public static function createContext(mixed $context, array $options, array $comp
181180 return new RuntimeContext (
182181 helpers: $ parentCx ->helpers ,
183182 partials: $ parentCx ->partials ,
183+ partialResolver: $ parentCx ->partialResolver ,
184184 inlinePartials: $ parentCx ->inlinePartials ,
185185 depths: $ parentCx ->depths ,
186186 data: $ data ,
@@ -190,7 +190,8 @@ public static function createContext(mixed $context, array $options, array $comp
190190 $ data ['root ' ] ??= $ context ;
191191 return new RuntimeContext (
192192 helpers: array_replace (Runtime::defaultHelpers (), $ options ['helpers ' ] ?? []),
193- partials: array_replace ($ compiledPartials , $ options ['partials ' ] ?? []),
193+ partials: $ options ['partials ' ] ?? [],
194+ partialResolver: $ options ['partialResolver ' ] ?? null ,
194195 data: $ data ,
195196 );
196197 }
@@ -387,8 +388,20 @@ private static function extend(mixed $a, mixed $b): mixed
387388 return $ a ;
388389 }
389390
391+ private static function resolveAndCachePartial (RuntimeContext $ cx , string $ name ): ?Closure
392+ {
393+ if ($ cx ->partialResolver === null ) {
394+ return null ;
395+ }
396+ $ fn = ($ cx ->partialResolver )($ name );
397+ if ($ fn !== null ) {
398+ $ cx ->partials [$ name ] = $ fn ;
399+ }
400+ return $ fn ;
401+ }
402+
390403 /**
391- * @param array<string, mixed> $hash named hash overrides merged into the context
404+ * @param array<mixed> $hash named hash overrides merged into the context
392405 * @param string $indent whitespace to prepend to each line of the partial's output
393406 * @param mixed $callerIn When compat mode is enabled, the caller's current $in pushed onto depths so
394407 * the partial can walk up to the caller's scope (mirrors HBS.js compat depths).
@@ -402,10 +415,17 @@ public static function invokePartial(RuntimeContext $cx, ?string $name, mixed $c
402415 null => null ,
403416 // inlinePartials (block-scoped {{#* inline}}) take precedence over partials (persistent),
404417 // mirroring Handlebars.js which checks options.partials before env.partials.
405- default => $ cx ->inlinePartials [$ name ] ?? $ cx ->partials [$ name ] ?? null ,
418+ // Falls back to partialResolver for lazy loading; result is cached in $cx->partials.
419+ default => $ cx ->inlinePartials [$ name ] ?? $ cx ->partials [$ name ] ?? self ::resolveAndCachePartial ($ cx , $ name ),
406420 };
407421
422+ $ context = $ hash ? self ::extend ($ context , $ hash ) : $ context ;
423+
408424 if ($ fn === null ) {
425+ if ($ partialBlock !== null && $ name !== null ) {
426+ // Partial not found; render the block body as failover (mirrors HBS.js behavior).
427+ return $ partialBlock ($ context , ['data ' => $ cx ->data , '_cx ' => $ cx ]);
428+ }
409429 $ name ??= 'undefined ' ; // match HBS.js error
410430 throw new \Exception ("The partial $ name could not be found " );
411431 }
@@ -422,7 +442,6 @@ public static function invokePartial(RuntimeContext $cx, ?string $name, mixed $c
422442 };
423443 }
424444
425- $ context = $ hash ? self ::extend ($ context , $ hash ) : $ context ;
426445 // In compat mode, push the caller's current context onto depths before creating the partial
427446 // context so the partial can walk up to the caller's scope. createContext() (called inside
428447 // the partial closure) copies $parentCx->depths, so the push must happen here, before $fn().
@@ -483,7 +502,7 @@ public static function resolveHelper(RuntimeContext $cx, string $name, mixed $ca
483502 * Equivalent to the invokeHelper/invokeKnownHelper opcodes in the Handlebars.js compiler.
484503 *
485504 * @param array<mixed> $positional
486- * @param array<string, mixed> $hash
505+ * @param array<mixed> $hash
487506 */
488507 public static function invokeHelper (RuntimeContext $ cx , Closure $ helper , string $ name , array $ positional , array $ hash , mixed &$ _this ): mixed
489508 {
@@ -517,7 +536,7 @@ public static function invokeHelper(RuntimeContext $cx, Closure $helper, string
517536 * Invoke a resolved block helper Closure with fn/inverse callbacks and a HelperOptions instance.
518537 *
519538 * @param array<mixed> $positional
520- * @param array<string, mixed> $hash
539+ * @param array<mixed> $hash
521540 * @param mixed $_this current rendering context for the helper
522541 * @param Closure|null $cb callback function to render child context (null for inverted blocks)
523542 * @param Closure|null $else callback function to render child context when {{else}}
0 commit comments