Skip to content

Commit 2c55b09

Browse files
committed
Allow helpers to dynamically register partials
1 parent 54cbaf1 commit 2c55b09

5 files changed

Lines changed: 90 additions & 11 deletions

File tree

src/Compiler.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -472,7 +472,7 @@ private function PartialBlockStatement(PartialBlockStatement $statement): string
472472

473473
if ($partialName !== null && !$found) {
474474
// Register the block body as a fallback partial only if no runtime partial with this name exists yet.
475-
$parts[] = "(isset(\$cx->partials[$p]) ? '' : " . self::getRuntimeFunc('in', "\$cx, $p, $bodyClosure") . ')';
475+
$parts[] = "(isset(\$cx->inlinePartials[$p]) || isset(\$cx->partials[$p]) ? '' : " . self::getRuntimeFunc('in', "\$cx, $p, $bodyClosure") . ')';
476476
}
477477
$parts[] = self::getRuntimeFunc('p', "\$cx, $p, $vars, '', $bodyClosure");
478478
return implode('.', $parts);

src/HelperOptions.php

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,23 @@ public function __construct(
3030
private readonly array $outerBlockParams = [],
3131
) {}
3232

33+
/**
34+
* Returns true if a partial with the given name is registered in the current render context.
35+
*/
36+
public function hasPartial(string $name): bool
37+
{
38+
return isset($this->cx->inlinePartials[$name]) || isset($this->cx->partials[$name]);
39+
}
40+
41+
/**
42+
* Registers a compiled partial closure under the given name for use in the current render context.
43+
* Typically used alongside hasPartial() to implement lazy partial loading.
44+
*/
45+
public function registerPartial(string $name, \Closure $partial): void
46+
{
47+
$this->cx->partials[$name] = $partial;
48+
}
49+
3350
/**
3451
* Allows isset($options->fn) and isset($options->inverse) to check whether the block exists.
3552
*/
@@ -54,17 +71,17 @@ public function fn(mixed $context = Scope::Use, mixed $data = null): string
5471
$cx = $this->cx;
5572
$scope = $this->scope;
5673

57-
// Save partials so that any {{#* inline}} partials registered inside the block body
74+
// Save inlinePartials so that any {{#* inline}} partials registered inside the block body
5875
// don't leak out after fn() returns. The spec requires inline partials to be
5976
// block-scoped. PHP copy-on-write makes this assignment cheap when no inline partials are registered.
60-
$savedPartials = $cx->partials;
77+
$savedInlinePartials = $cx->inlinePartials;
6178

6279
// Skip depths push for explicit same-context pass (equivalent to HBS.js options.fn(this))
6380
$skipDepths = $context === $scope;
6481
$resolvedContext = $skipDepths ? $scope : ($context === Scope::Use ? $scope : $context);
6582
$ret = $this->callBlock($this->cb, $resolvedContext, !$skipDepths, $data);
6683

67-
$cx->partials = $savedPartials;
84+
$cx->inlinePartials = $savedInlinePartials;
6885
return $ret;
6986
}
7087

@@ -136,9 +153,9 @@ public function iterate(array $items): string
136153
$cx = $this->cx;
137154
$cb = $this->cb;
138155

139-
// Push depths and save partials once for the entire loop.
156+
// Push depths and save inlinePartials once for the entire loop.
140157
$cx->depths[] = $this->scope;
141-
$savedPartials = $cx->partials;
158+
$savedInlinePartials = $cx->inlinePartials;
142159

143160
$last = count($items) - 1;
144161
$ret = '';
@@ -166,7 +183,7 @@ public function iterate(array $items): string
166183

167184
$cx->frame = $outerFrame;
168185
array_pop($cx->depths);
169-
$cx->partials = $savedPartials;
186+
$cx->inlinePartials = $savedInlinePartials;
170187
return $ret;
171188
}
172189
}

src/Runtime.php

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -158,12 +158,13 @@ public static function createContext(mixed $context, array $options, array $comp
158158

159159
if ($parentCx !== null) {
160160
// Partial context: reuse the parent's already-merged helpers and partials directly.
161-
// PHP copy-on-write ensures partials is only copied if in() registers a new inline partial.
161+
// PHP copy-on-write ensures inlinePartials is only copied if in() registers a new inline partial.
162162
// Inherit the parent's current frame so @index, @key, etc. remain accessible inside partials.
163163
// templateClosure will update frame['root'] to reference this partial's own data['root'].
164164
return new RuntimeContext(
165165
helpers: $parentCx->helpers,
166166
partials: $parentCx->partials,
167+
inlinePartials: $parentCx->inlinePartials,
167168
depths: $parentCx->depths,
168169
data: $root,
169170
frame: $parentCx->frame,
@@ -368,7 +369,9 @@ public static function merge(mixed $a, mixed $b): mixed
368369
*/
369370
public static function p(RuntimeContext $cx, string $name, mixed $context, array $hash, string $indent, ?\Closure $partialBlock = null): string
370371
{
371-
$fn = $name === '@partial-block' ? $cx->partialBlock : ($cx->partials[$name] ?? null);
372+
// inlinePartials (block-scoped {{#* inline}}) take precedence over partials (persistent),
373+
// mirroring Handlebars.js which checks options.partials before env.partials.
374+
$fn = $name === '@partial-block' ? $cx->partialBlock : ($cx->inlinePartials[$name] ?? $cx->partials[$name] ?? null);
372375
if ($fn === null) {
373376
throw new \Exception("The partial $name could not be found");
374377
}
@@ -425,7 +428,7 @@ public static function p(RuntimeContext $cx, string $name, mixed $context, array
425428
*/
426429
public static function in(RuntimeContext $cx, string $name, \Closure $partial): string
427430
{
428-
$cx->partials[$name] = $partial;
431+
$cx->inlinePartials[$name] = $partial;
429432
return '';
430433
}
431434

src/RuntimeContext.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,16 @@ final class RuntimeContext
99
{
1010
/**
1111
* @param array<string, \Closure> $helpers
12-
* @param array<string, \Closure> $partials
12+
* @param array<string, \Closure> $partials compile-time and helper-registered partials (persistent)
13+
* @param array<string, \Closure> $inlinePartials block-scoped {{#* inline}} partials (reset on fn() return)
1314
* @param array<mixed> $depths
1415
* @param array<mixed> $data
1516
* @param array<mixed> $frame
1617
*/
1718
public function __construct(
1819
public array $helpers = [],
1920
public array $partials = [],
21+
public array $inlinePartials = [],
2022
public array $depths = [],
2123
public array $data = [],
2224
public array $frame = [],

tests/RegressionTest.php

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1104,6 +1104,25 @@ public static function partialProvider(): array
11041104
'expected' => 'This is Lucky, it is 5 years old.',
11051105
],
11061106

1107+
[
1108+
'desc' => 'loadPartial: hasPartial returns false before registration',
1109+
'template' => '{{check}} {{> (loadPartial partialName)}} {{check}}',
1110+
'data' => ['partialName' => 'greet', 'name' => 'World'],
1111+
'helpers' => [
1112+
'check' => function (HelperOptions $options): string {
1113+
return $options->hasPartial('greet') ? 'found' : 'missing';
1114+
},
1115+
'loadPartial' => function (string $name, HelperOptions $options): string {
1116+
if (!$options->hasPartial($name)) {
1117+
$partial = 'Hello {{name}}';
1118+
$options->registerPartial($name, Handlebars::compile($partial));
1119+
}
1120+
return $name;
1121+
},
1122+
],
1123+
'expected' => 'missing Hello World found',
1124+
],
1125+
11071126
[
11081127
'desc' => 'LNC#241 - each block inside inline block context',
11091128
'template' => '{{#>foo}}{{#*inline "bar"}}GOOD!{{#each .}}>{{.}}{{/each}}{{/inline}}{{/foo}}',
@@ -2188,4 +2207,42 @@ public static function subexpressionPathProvider(): array
21882207
],
21892208
];
21902209
}
2210+
2211+
public function testLoadPartialPersistsAcrossFnCalls(): void
2212+
{
2213+
// registerPartial() writes to the persistent $cx->partials array, which fn() does not
2214+
// reset, so each template is compiled and registered only once even when a block helper
2215+
// calls fn() per iteration.
2216+
$compileCounts = ['a' => 0, 'b' => 0];
2217+
2218+
$helpers = [
2219+
'repeat' => function (array $items, HelperOptions $options): string {
2220+
$ret = '';
2221+
foreach ($items as $item) {
2222+
$ret .= $options->fn($item);
2223+
}
2224+
return $ret;
2225+
},
2226+
'loadPartial' => function (string $name, HelperOptions $options) use (&$compileCounts): string {
2227+
if (!$options->hasPartial($name)) {
2228+
$templates = ['a' => '[A:{{val}}]', 'b' => '[B:{{val}}]'];
2229+
$options->registerPartial($name, Handlebars::compile($templates[$name]));
2230+
$compileCounts[$name]++;
2231+
}
2232+
return $name;
2233+
},
2234+
];
2235+
2236+
$template = Handlebars::compile('{{#repeat items}}{{> (loadPartial type)}}{{/repeat}}');
2237+
$items = [
2238+
['type' => 'a', 'val' => 1],
2239+
['type' => 'b', 'val' => 2],
2240+
['type' => 'a', 'val' => 3],
2241+
];
2242+
$result = $template(['items' => $items], ['helpers' => $helpers]);
2243+
2244+
$this->assertEquals('[A:1][B:2][A:3]', $result);
2245+
$this->assertEquals(1, $compileCounts['a']);
2246+
$this->assertEquals(1, $compileCounts['b']);
2247+
}
21912248
}

0 commit comments

Comments
 (0)