Skip to content

Commit c5bc684

Browse files
committed
Move partial resolution from compile time to runtime
Compile-time partials were embedded directly into every template referencing them, causing the same partial to be recompiled and duplicated across all templates that shared it, which was inefficient and inflexible. This commit removes the `partials` and `partialResolver` compile options, and adds a new `partialResolver` runtime option. This allows all partials in a directory to be precompiled once, and then lazily resolved at runtime and reused across any number of templates without redundant work.
1 parent ddc1fe6 commit c5bc684

7 files changed

Lines changed: 88 additions & 202 deletions

File tree

src/Compiler.php

Lines changed: 33 additions & 131 deletions
Large diffs are not rendered by default.

src/Context.php

Lines changed: 0 additions & 20 deletions
This file was deleted.

src/Handlebars.php

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,18 @@
77
use DevTheorem\HandlebarsParser\ParserFactory;
88

99
/**
10-
* @phpstan-type RenderOptions array{data?: array<mixed>, helpers?: array<string, Closure>, partials?: array<string, Closure>}
10+
* @phpstan-type RenderOptions array{
11+
* data?: array<mixed>,
12+
* helpers?: array<Closure>,
13+
* partials?: array<Closure>,
14+
* partialResolver?: Closure(string): ?Closure,
15+
* }
1116
* @phpstan-type Template Closure(mixed=, RenderOptions=): string
1217
*/
1318
final class Handlebars
1419
{
1520
private static ?Parser $parser = null;
16-
private static ?Compiler $compiler = null;
21+
private static Compiler $compiler;
1722

1823
/**
1924
* Compiles a template so it can be executed immediately.
@@ -30,12 +35,10 @@ public static function compile(string $template, Options $options = new Options(
3035
public static function precompile(string $template, Options $options = new Options()): string
3136
{
3237
self::$parser ??= (new ParserFactory())->create();
33-
self::$compiler ??= new Compiler(self::$parser);
38+
self::$compiler ??= new Compiler();
3439

35-
$context = new Context($options);
3640
$program = self::$parser->parse($template, $options->ignoreStandalone);
37-
$code = self::$compiler->compile($program, $context);
38-
self::$compiler->handleDynamicPartials();
41+
$code = self::$compiler->compile($program, $options);
3942

4043
return self::$compiler->composePHPRender($code);
4144
}

src/Options.php

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,13 @@
22

33
namespace DevTheorem\Handlebars;
44

5-
use Closure;
6-
75
final readonly class Options
86
{
9-
/** @var array<string, bool> */
7+
/** @var array<bool> */
108
public array $knownHelpers;
119

1210
/**
13-
* @param array<string, bool> $knownHelpers
14-
* @param array<string, string> $partials
15-
* @param null|Closure(string):(string|null) $partialResolver
11+
* @param array<bool> $knownHelpers
1612
*/
1713
public function __construct(
1814
public bool $compat = false,
@@ -24,8 +20,6 @@ public function __construct(
2420
public bool $preventIndent = false,
2521
public bool $ignoreStandalone = false,
2622
public bool $explicitPartialContext = false,
27-
public array $partials = [],
28-
public ?Closure $partialResolver = null,
2923
) {
3024
$builtIn = ['if' => true, 'unless' => true, 'each' => true, 'with' => true, 'lookup' => true, 'log' => true];
3125
$this->knownHelpers = $knownHelpers ? array_replace($builtIn, $knownHelpers) : $builtIn;

src/Runtime.php

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -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}}

src/RuntimeContext.php

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,16 @@
1010
final class RuntimeContext
1111
{
1212
/**
13-
* @param array<string, Closure> $helpers
14-
* @param array<string, Closure> $partials compile-time and helper-registered partials (persistent)
15-
* @param array<string, Closure> $inlinePartials block-scoped {{#* inline}} partials (reset on fn() return)
13+
* @param array<Closure> $helpers
14+
* @param array<Closure> $partials runtime-registered partials (persistent)
15+
* @param array<Closure> $inlinePartials block-scoped {{#* inline}} partials (reset on fn() return)
1616
* @param array<mixed> $depths
1717
* @param array<mixed> $data
1818
*/
1919
public function __construct(
2020
public array $helpers = [],
2121
public array $partials = [],
22+
public ?Closure $partialResolver = null,
2223
public array $inlinePartials = [],
2324
public array $depths = [],
2425
public array $data = [],

tests/RegressionTest.php

Lines changed: 11 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
* @phpstan-type RegIssue array{
1515
* template: string, expected: string, data?: mixed, options?: Options,
1616
* helpers?: array<Closure>, partials?: array<string>,
17-
* vars?: array<mixed>,
17+
* partialResolver?: Closure, vars?: array<mixed>,
1818
* }
1919
*/
2020
class RegressionTest extends TestCase
@@ -74,6 +74,7 @@ public function testIssues(
7474
?Options $options = null,
7575
array $helpers = [],
7676
array $partials = [],
77+
?Closure $partialResolver = null,
7778
array $vars = [],
7879
): void {
7980
$options ??= new Options();
@@ -82,7 +83,11 @@ public function testIssues(
8283
try {
8384
$template = Handlebars::template($templateSpec);
8485
$compiledPartials = array_map(fn($p) => Handlebars::compile($p, $options), $partials);
85-
$result = $template($data, ['helpers' => $helpers, 'partials' => $compiledPartials, 'data' => $vars]);
86+
$runtimeOptions = ['helpers' => $helpers, 'partials' => $compiledPartials, 'data' => $vars];
87+
if ($partialResolver) {
88+
$runtimeOptions['partialResolver'] = $partialResolver;
89+
}
90+
$result = $template($data, $runtimeOptions);
8691
} catch (\Throwable $e) {
8792
$this->fail("Error: {$e->getMessage()}\nPHP code:\n$templateSpec");
8893
}
@@ -1144,9 +1149,7 @@ public static function partialProvider(): array
11441149

11451150
'partial resolver callback' => [
11461151
'template' => '{{>foo}} and {{>bar}}',
1147-
'options' => new Options(
1148-
partialResolver: fn(string $name) => "PARTIAL: $name",
1149-
),
1152+
'partialResolver' => fn(string $name) => Handlebars::compile("PARTIAL: $name"),
11501153
'expected' => 'PARTIAL: foo and PARTIAL: bar',
11511154
],
11521155

@@ -1216,32 +1219,16 @@ public static function nestedPartialProvider(): array
12161219
'expected' => 'outer+nested=~content~=nested-end+outer-end',
12171220
],
12181221

1219-
'LNC#292 - nested compile-time and runtime partials should render correctly' => [
1222+
'LNC#292 - nested partials should render correctly' => [
12201223
'template' => '{{#>outer}} {{#>compiledBlock}} inner compiledBlock {{/compiledBlock}} {{>normalTemplate}} {{/outer}}',
1221-
'options' => new Options(
1222-
partials: [
1223-
'outer' => 'outer+{{#>nested}}~{{>@partial-block}}~{{/nested}}+outer-end',
1224-
'nested' => 'nested={{>@partial-block}}=nested-end',
1225-
],
1226-
),
12271224
'partials' => [
1225+
'outer' => 'outer+{{#>nested}}~{{>@partial-block}}~{{/nested}}+outer-end',
1226+
'nested' => 'nested={{>@partial-block}}=nested-end',
12281227
'compiledBlock' => 'compiledBlock !!! {{>@partial-block}} !!! compiledBlock',
12291228
'normalTemplate' => 'normalTemplate',
12301229
],
12311230
'expected' => 'outer+nested=~ compiledBlock !!! inner compiledBlock !!! compiledBlock normalTemplate ~=nested-end+outer-end',
12321231
],
1233-
'LNC#292 - nested compile-time partials should render correctly' => [
1234-
'template' => '{ {{#>outer}} {{#>innerBlock}} Hello {{/innerBlock}} {{>simple}} {{/outer}} }',
1235-
'options' => new Options(
1236-
partials: [
1237-
'outer' => '( {{#>nested}} « {{>@partial-block}} » {{/nested}} )',
1238-
'nested' => '[ {{>@partial-block}} ]',
1239-
'innerBlock' => '< {{>@partial-block}} >',
1240-
'simple' => 'World!',
1241-
],
1242-
),
1243-
'expected' => '{ ( [ « < Hello > World! » ] ) }',
1244-
],
12451232
'LNC#292 - nested runtime partials should render correctly' => [
12461233
'template' => '{ {{#>outer}} {{#>innerBlock}} Hello {{/innerBlock}} {{>simple}} {{/outer}} }',
12471234
'partials' => [

0 commit comments

Comments
 (0)