@@ -14,8 +14,6 @@ final class Runtime
1414{
1515 /** @var array<string, Closure>|null */
1616 private static ?array $ defaultHelpers = null ;
17- /** Parent RuntimeContext during a user-partial invocation, null at top level. */
18- private static ?RuntimeContext $ partialContext = null ;
1917
2018 /**
2119 * Default implementations of the built-in Handlebars helpers.
@@ -165,20 +163,20 @@ public static function lookupLength(mixed $base, bool $strict = false): mixed
165163 /**
166164 * Build a RuntimeContext from raw render options and compile-time partial closures.
167165 *
168- * @param RenderOptions $options
166+ * @param RenderOptions|array{_cx?: RuntimeContext} $options
169167 * @param array<string, Closure> $compiledPartials
170168 */
171169 public static function createContext (mixed $ context , array $ options , array $ compiledPartials ): RuntimeContext
172170 {
173- $ parentCx = self :: $ partialContext ;
171+ $ parentCx = $ options [ ' _cx ' ] ?? null ;
174172
175173 if ($ parentCx !== null ) {
176174 // Partial context: reuse the parent's already-merged helpers and partials directly.
177175 // PHP copy-on-write ensures inlinePartials is only copied if the partial registers a new {{#* inline}} partial.
178- // Inherit the parent 's current data so @index, @key, etc. remain accessible inside partials .
176+ // p() always passes the caller 's current data frame via options, so partials inherit @index, @key, etc.
179177 // Unset 'root' first to break the reference established by `$in = &$cx->data['root']` in the
180178 // calling template; a direct assignment would write through it and corrupt the caller's $in.
181- $ data = $ parentCx -> data ;
179+ $ data = $ options [ ' data ' ] ?? [] ;
182180 unset($ data ['root ' ]);
183181 $ data ['root ' ] = $ context ;
184182 return new RuntimeContext (
@@ -187,12 +185,11 @@ public static function createContext(mixed $context, array $options, array $comp
187185 inlinePartials: $ parentCx ->inlinePartials ,
188186 depths: $ parentCx ->depths ,
189187 data: $ data ,
190- partialBlock: $ parentCx ->partialBlock ,
191188 );
192189 }
193190
194191 $ data = $ options ['data ' ] ?? [];
195- $ data ['root ' ] = $ data [ ' root ' ] ?? $ context ;
192+ $ data ['root ' ] ??= $ context ;
196193 $ extraHelpers = $ options ['helpers ' ] ?? [];
197194 return new RuntimeContext (
198195 helpers: $ extraHelpers ? array_replace (Runtime::defaultHelpers (), $ extraHelpers ) : Runtime::defaultHelpers (),
@@ -392,7 +389,7 @@ public static function merge(mixed $a, mixed $b): mixed
392389 }
393390
394391 /**
395- * Call {{> partial}}
392+ * Equivalent to invokePartial in the Handlebars.js runtime.
396393 * @param array<string, mixed> $hash named hash overrides merged into the context
397394 * @param string $indent whitespace to prepend to each line of the partial's output
398395 * @param mixed $callerIn When compat mode is enabled, the caller's current $in pushed onto depths so
@@ -401,7 +398,8 @@ public static function merge(mixed $a, mixed $b): mixed
401398 public static function p (RuntimeContext $ cx , ?string $ name , mixed $ context , array $ hash , string $ indent , ?Closure $ partialBlock = null , mixed $ callerIn = null ): string
402399 {
403400 $ fn = match ($ name ) {
404- '@partial-block ' => $ cx ->partialBlock ,
401+ // @partial-block is resolved from data, mirroring HBS.js resolvePartial
402+ '@partial-block ' => $ cx ->data ['partial-block ' ] ?? null ,
405403 // name can be null if a dynamic partial doesn't resolve to anything
406404 null => null ,
407405 // inlinePartials (block-scoped {{#* inline}}) take precedence over partials (persistent),
@@ -415,18 +413,14 @@ public static function p(RuntimeContext $cx, ?string $name, mixed $context, arra
415413 }
416414
417415 // Install a wrapper as the active @partial-block so the partial can invoke it via {{> @partial-block}}.
418- // The wrapper temporarily restores the previously active block before calling $partialBlock,
419- // allowing nested partial blocks to correctly resolve their own @partial- block.
416+ // Mirrors HBS.js invokePartial: the wrapper receives the current data frame, restores partial-block to
417+ // currentPartialBlock (captured from the closure), then calls the block content with that frame .
420418 if ($ partialBlock !== null ) {
421- $ currentBlock = $ cx ->partialBlock ;
422- $ cx ->partialBlock = static function (mixed $ blockContext ) use ($ partialBlock , $ currentBlock ): string {
423- $ callingCx = self ::$ partialContext ;
424- assert ($ callingCx !== null );
425- $ saved = $ callingCx ->partialBlock ;
426- $ callingCx ->partialBlock = $ currentBlock ;
427- $ result = $ partialBlock ($ blockContext );
428- $ callingCx ->partialBlock = $ saved ;
429- return $ result ;
419+ $ currentBlock = $ cx ->data ['partial-block ' ] ?? null ;
420+ $ cx ->data ['partial-block ' ] = static function (mixed $ context = null , array $ options = []) use ($ partialBlock , $ currentBlock ): string {
421+ $ options ['data ' ] ??= [];
422+ $ options ['data ' ]['partial-block ' ] = $ currentBlock ;
423+ return $ partialBlock ($ context , $ options );
430424 };
431425 }
432426
@@ -437,17 +431,14 @@ public static function p(RuntimeContext $cx, ?string $name, mixed $context, arra
437431 if ($ callerIn !== null ) {
438432 $ cx ->depths [] = $ callerIn ;
439433 }
440- $ prev = self ::$ partialContext ;
441- self ::$ partialContext = $ cx ;
442434 try {
443- $ result = $ fn ($ context );
435+ $ result = $ fn ($ context, [ ' data ' => $ cx -> data , ' _cx ' => $ cx ] );
444436 } finally {
445- self ::$ partialContext = $ prev ;
446437 if ($ callerIn !== null ) {
447438 array_pop ($ cx ->depths );
448439 }
449440 if ($ partialBlock !== null ) {
450- $ cx ->partialBlock = $ currentBlock ;
441+ $ cx ->data [ ' partial-block ' ] = $ currentBlock ;
451442 }
452443 }
453444
0 commit comments