From 462755f1e70ec24467b38e19becdd1ca64be3880 Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Mon, 3 Aug 2026 16:58:04 +0200 Subject: [PATCH 01/11] reuse shared context instance --- src/Render/RenderContext.php | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/Render/RenderContext.php b/src/Render/RenderContext.php index e2bdac7..08079c5 100644 --- a/src/Render/RenderContext.php +++ b/src/Render/RenderContext.php @@ -88,6 +88,11 @@ public function __construct( public readonly RenderContextOptions $options = new RenderContextOptions, ?ResourceLimits $resourceLimits = null, ?Environment $environment = null, + /** + * Sub-contexts inherit the parent state; building a fresh one here would + * merge the environment registers only to have it replaced. + */ + ?ContextSharedState $sharedState = null, ) { $this->environment = $environment ?? Environment::default(); $this->resourceLimits = $resourceLimits ?? ResourceLimits::clone($this->environment->defaultResourceLimits); @@ -95,7 +100,7 @@ public function __construct( $this->scopes = [[]]; - $this->sharedState = new ContextSharedState( + $this->sharedState = $sharedState ?? new ContextSharedState( staticVariables: $staticData, registers: array_merge($this->environment->getRegisters(), $registers), ); @@ -458,9 +463,9 @@ public function newIsolatedSubContext(?string $templateName = null, ?RenderConte options: $options ?? $this->options, resourceLimits: $this->resourceLimits, environment: $this->environment, + sharedState: $this->sharedState, ); $subContext->baseScopeDepth = $this->baseScopeDepth + 1; - $subContext->sharedState = $this->sharedState; $subContext->templateName = $templateName; $subContext->partial = true; From 7c1e276c014af0c8b2c8a3667853dafdef57276c Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Mon, 3 Aug 2026 16:59:13 +0200 Subject: [PATCH 02/11] Arr::set fast path --- src/Support/Arr.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/Support/Arr.php b/src/Support/Arr.php index 64b283c..7ecddd1 100644 --- a/src/Support/Arr.php +++ b/src/Support/Arr.php @@ -39,6 +39,14 @@ public static function has(array $array, string $key): bool public static function set(array &$array, string|int $key, mixed $value): array { + // A key without a path is the common case and needs no walking: scope + // assignment goes through here for every loop variable and every partial. + if (! str_contains((string) $key, '.')) { + $array[$key] = $value; + + return $array; + } + $keys = explode('.', (string) $key); foreach ($keys as $i => $key) { From fa194adba994a2c9c913830d80db2b66f0e5f448 Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Mon, 3 Aug 2026 17:30:35 +0200 Subject: [PATCH 03/11] updated ThemeBench --- performance/benchmarks/ThemeBench.php | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/performance/benchmarks/ThemeBench.php b/performance/benchmarks/ThemeBench.php index a327ab8..b8f7177 100644 --- a/performance/benchmarks/ThemeBench.php +++ b/performance/benchmarks/ThemeBench.php @@ -80,8 +80,18 @@ public function benchRender(): void public function benchStream(): void { foreach ($this->pageTemplateNames as $pageTemplateName) { - foreach (StorefrontTheme::streamPage($this->environment, $pageTemplateName) as $chunk) { - } + $this->drain(StorefrontTheme::streamPage($this->environment, $pageTemplateName)); } } + + /** + * @param \Generator $stream + */ + private function drain(\Generator $stream): void + { + while ($stream->valid()) { + $stream->next(); + } + } + } From 8b868397ecb1202e623301fcb98a35a0b054b24a Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Mon, 3 Aug 2026 17:46:27 +0200 Subject: [PATCH 04/11] feat: stream control-flow tags --- performance/benchmarks/ThemeBench.php | 1 - src/Render/RenderContext.php | 16 +++++++ src/Tags/CaseTag.php | 21 ++++++++- src/Tags/ForTag.php | 63 ++++++++++++++++++++++++- src/Tags/IfTag.php | 30 +++++++++++- src/Tags/UnlessTag.php | 18 ++++++++ tests/Integration/StreamTest.php | 66 +++++++++++++++++++++++++-- 7 files changed, 207 insertions(+), 8 deletions(-) diff --git a/performance/benchmarks/ThemeBench.php b/performance/benchmarks/ThemeBench.php index b8f7177..df958db 100644 --- a/performance/benchmarks/ThemeBench.php +++ b/performance/benchmarks/ThemeBench.php @@ -93,5 +93,4 @@ private function drain(\Generator $stream): void $stream->next(); } } - } diff --git a/src/Render/RenderContext.php b/src/Render/RenderContext.php index 08079c5..9a48002 100644 --- a/src/Render/RenderContext.php +++ b/src/Render/RenderContext.php @@ -4,6 +4,7 @@ use ArithmeticError; use Closure; +use Generator; use Keepsuit\Liquid\Contracts\CanBeEvaluated; use Keepsuit\Liquid\Contracts\IsContextAware; use Keepsuit\Liquid\Contracts\LiquidErrorHandler; @@ -147,6 +148,21 @@ public function stack(Closure $closure) return $result; } + /** + * @param Closure(RenderContext $context): Generator $closure + * @return Generator + */ + public function streamedStack(Closure $closure) + { + $this->push(); + + try { + yield from $closure($this); + } finally { + $this->pop(); + } + } + public function evaluate(mixed $value): mixed { while ($value instanceof CanBeEvaluated) { diff --git a/src/Tags/CaseTag.php b/src/Tags/CaseTag.php index edabddb..51c5d25 100644 --- a/src/Tags/CaseTag.php +++ b/src/Tags/CaseTag.php @@ -4,6 +4,7 @@ use Keepsuit\Liquid\Condition\Condition; use Keepsuit\Liquid\Condition\ElseCondition; +use Keepsuit\Liquid\Contracts\CanBeStreamed; use Keepsuit\Liquid\Exceptions\SyntaxException; use Keepsuit\Liquid\Nodes\BodyNode; use Keepsuit\Liquid\Parse\ExpressionParser; @@ -15,7 +16,7 @@ /** * @phpstan-import-type Expression from ExpressionParser */ -class CaseTag extends TagBlock +class CaseTag extends TagBlock implements CanBeStreamed { /** @var Condition[] */ protected array $conditions = []; @@ -66,6 +67,24 @@ public function render(RenderContext $context): string return ''; } + /** + * @return \Generator + */ + public function stream(RenderContext $context): \Generator + { + foreach ($this->conditions as $condition) { + if (! $condition->else() && ! $condition->evaluate($context)) { + continue; + } + + if ($condition->body !== null) { + yield from $condition->body->stream($context); + } + + return; + } + } + public function children(): array { return array_filter( diff --git a/src/Tags/ForTag.php b/src/Tags/ForTag.php index e11b7ea..df52536 100644 --- a/src/Tags/ForTag.php +++ b/src/Tags/ForTag.php @@ -2,6 +2,7 @@ namespace Keepsuit\Liquid\Tags; +use Keepsuit\Liquid\Contracts\CanBeStreamed; use Keepsuit\Liquid\Contracts\HasParseTreeVisitorChildren; use Keepsuit\Liquid\Drops\ForLoopDrop; use Keepsuit\Liquid\Exceptions\InvalidArgumentException; @@ -23,7 +24,7 @@ /** * @phpstan-import-type Expression from ExpressionParser */ -class ForTag extends TagBlock implements HasParseTreeVisitorChildren +class ForTag extends TagBlock implements CanBeStreamed, HasParseTreeVisitorChildren { protected string $variableName; @@ -82,6 +83,24 @@ public function render(RenderContext $context): string return $this->renderSegment($context, $segment); } + /** + * @return \Generator + */ + public function stream(RenderContext $context): \Generator + { + $segment = $this->collectionSegment($context); + + if ($segment === []) { + if ($this->elseBlock !== null) { + yield from $this->elseBlock->stream($context); + } + + return; + } + + yield from $this->streamSegment($context, $segment); + } + public function children(): array { return $this->elseBlock ? [$this->forBlock, $this->elseBlock] : [$this->forBlock]; @@ -211,6 +230,48 @@ protected function renderSegment(RenderContext $context, array $segment): string }); } + /** + * @return \Generator + */ + protected function streamSegment(RenderContext $context, array $segment): \Generator + { + /** @var ForLoopDrop[] $forStack */ + $forStack = $context->getRegister('for_stack') ?? []; + assert(is_array($forStack)); + + yield from $context->streamedStack(function () use ($context, $segment, $forStack): \Generator { + $loopVars = new ForLoopDrop( + name: $this->name, + length: count($segment), + parentLoop: $forStack !== [] ? $forStack[count($forStack) - 1] : null, + ); + + $forStack[] = $loopVars; + $context->setRegister('for_stack', $forStack); + + try { + $context->set('forloop', $loopVars); + + foreach ($segment as $value) { + $context->set($this->variableName, $value); + yield from $this->forBlock->stream($context); + $loopVars->increment(); + + $interrupt = $context->popInterrupt(); + + if ($interrupt instanceof BreakInterrupt) { + break; + } + } + } finally { + $forStack = $context->getRegister('for_stack'); + assert(is_array($forStack)); + array_pop($forStack); + $context->setRegister('for_stack', $forStack); + } + }); + } + protected function renderElse(RenderContext $context): string { return $this->elseBlock?->render($context) ?? ''; diff --git a/src/Tags/IfTag.php b/src/Tags/IfTag.php index 644ed79..f38f086 100644 --- a/src/Tags/IfTag.php +++ b/src/Tags/IfTag.php @@ -4,6 +4,7 @@ use Keepsuit\Liquid\Condition\Condition; use Keepsuit\Liquid\Condition\ElseCondition; +use Keepsuit\Liquid\Contracts\CanBeStreamed; use Keepsuit\Liquid\Exceptions\SyntaxException; use Keepsuit\Liquid\Parse\TagParseContext; use Keepsuit\Liquid\Parse\TokenType; @@ -11,7 +12,7 @@ use Keepsuit\Liquid\Support\Arr; use Keepsuit\Liquid\TagBlock; -class IfTag extends TagBlock +class IfTag extends TagBlock implements CanBeStreamed { /** @var Condition[] */ protected array $conditions = []; @@ -51,6 +52,33 @@ public function render(RenderContext $context): string return $output; } + /** + * @return \Generator + */ + public function stream(RenderContext $context): \Generator + { + yield from $this->streamConditions($context, $this->conditions); + } + + /** + * @param array $conditions + * @return \Generator + */ + protected function streamConditions(RenderContext $context, array $conditions): \Generator + { + foreach ($conditions as $condition) { + if (! $condition->evaluate($context)) { + continue; + } + + if ($condition->body !== null) { + yield from $condition->body->stream($context); + } + + return; + } + } + public function parseTreeVisitorChildren(): array { return $this->conditions; diff --git a/src/Tags/UnlessTag.php b/src/Tags/UnlessTag.php index 0f0285f..08e6478 100644 --- a/src/Tags/UnlessTag.php +++ b/src/Tags/UnlessTag.php @@ -37,6 +37,24 @@ public function render(RenderContext $context): string return parent::render($context); } + /** + * @return \Generator + */ + public function stream(RenderContext $context): \Generator + { + $result = $this->unlessCondition?->evaluate($context); + + if (! $result) { + if ($this->unlessCondition?->body !== null) { + yield from $this->unlessCondition->body->stream($context); + } + + return; + } + + yield from $this->streamConditions($context, $this->conditions); + } + public function parseTreeVisitorChildren(): array { return [$this->unlessCondition, ...$this->conditions]; diff --git a/tests/Integration/StreamTest.php b/tests/Integration/StreamTest.php index 0282992..b7f5882 100644 --- a/tests/Integration/StreamTest.php +++ b/tests/Integration/StreamTest.php @@ -11,10 +11,15 @@ $output = iterator_to_array($stream); - expect($output) - ->toHaveCount(2) - ->{0}->toBe("text\n") - ->{1}->toBe("1\n2\n3\n"); + expect($output)->toBe([ + "text\n", + '1', + "\n", + '2', + "\n", + '3', + "\n", + ]); }); test('stream generator variable', function () { @@ -55,3 +60,56 @@ ->toHaveCount(1) ->{0}->toBe('text1,text2'); }); + +test('for tags stream their body chunks', function () { + $stream = streamTemplate( + '{% for item in items %}{{ item }}{% endfor %}', + staticData: ['items' => ['a', 'bb']], + ); + + expect(iterator_to_array($stream))->toBe([ + '', + 'a', + '', + '', + 'bb', + '', + ]); +}); + +test('if, unless, and case tags stream their selected body chunks', function () { + $stream = streamTemplate( + '{% if enabled %}if:{{ value }}{% else %}no{% endif %}' + .'{% unless disabled %}unless:{{ value }}{% else %}disabled{% endunless %}' + .'{% case value %}{% when "x" %}case:{{ value }}{% else %}other{% endcase %}', + staticData: [ + 'enabled' => true, + 'disabled' => false, + 'value' => 'x', + ], + ); + + expect(iterator_to_array($stream))->toBe([ + 'if:', + 'x', + 'unless:', + 'x', + 'case:', + 'x', + ]); +}); + +test('streamed for tags preserve break and continue behavior', function () { + $environment = \Keepsuit\Liquid\Environment::default(); + $template = $environment->parseString(<<<'LIQUID' + {% for item in items %}{{ item }}{% if item == 'b' %}{% continue %}{% endif %}x{% if item == 'c' %}{% break %}{% endif %}{% endfor %} + LIQUID + ); + + $context = $environment->newRenderContext(staticData: [ + 'items' => ['a', 'b', 'c', 'd'], + ]); + + expect(implode('', iterator_to_array($template->stream($context)))) + ->toBe('axbcx'); +}); From 0969e6d3297ffa66edd2d7986feaa895586f8625 Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Tue, 4 Aug 2026 10:19:22 +0200 Subject: [PATCH 05/11] Normalize iterable handling in loop tags - Convert iterable loop values consistently - Reject invalid loop inputs explicitly --- src/Tags/ForTag.php | 7 ++----- src/Tags/RenderTag.php | 45 ++++++++++++++++++++++++++++++++++++------ 2 files changed, 41 insertions(+), 11 deletions(-) diff --git a/src/Tags/ForTag.php b/src/Tags/ForTag.php index df52536..cdf020f 100644 --- a/src/Tags/ForTag.php +++ b/src/Tags/ForTag.php @@ -19,7 +19,6 @@ use Keepsuit\Liquid\Render\RenderContext; use Keepsuit\Liquid\Support\Arr; use Keepsuit\Liquid\TagBlock; -use Traversable; /** * @phpstan-import-type Expression from ExpressionParser @@ -153,11 +152,9 @@ protected function collectionSegment(RenderContext $context): array $collection = $context->evaluate($this->collection) ?? []; $collection = match (true) { $collection instanceof Range => $collection->toArray(), - $collection instanceof Traversable => iterator_to_array($collection), - is_iterable($collection) => (array) $collection, - default => $collection, + is_iterable($collection) => iterator_to_array($collection), + default => throw new InvalidArgumentException('Invalid array'), }; - assert(is_array($collection)); if ($this->from === 'continue') { $offset = $offsets[$this->name]; diff --git a/src/Tags/RenderTag.php b/src/Tags/RenderTag.php index 8f66954..833e261 100644 --- a/src/Tags/RenderTag.php +++ b/src/Tags/RenderTag.php @@ -5,6 +5,7 @@ use Keepsuit\Liquid\Contracts\CanBeStreamed; use Keepsuit\Liquid\Contracts\HasParseTreeVisitorChildren; use Keepsuit\Liquid\Drops\ForLoopDrop; +use Keepsuit\Liquid\Exceptions\InvalidArgumentException; use Keepsuit\Liquid\Exceptions\SyntaxException; use Keepsuit\Liquid\Nodes\VariableLookup; use Keepsuit\Liquid\Parse\ExpressionParser; @@ -14,7 +15,6 @@ use Keepsuit\Liquid\Support\Arr; use Keepsuit\Liquid\Tag; use Keepsuit\Liquid\Template; -use Traversable; /** * @phpstan-import-type Expression from ExpressionParser @@ -104,10 +104,32 @@ public function parse(TagParseContext $context): static public function render(RenderContext $context): string { + $partial = $this->loadPartial($context); + $templateName = $partial->name() ?? ''; + + $contextVariableName = ($this->aliasName ?? Arr::last(explode('/', $templateName))); + assert(is_string($contextVariableName)); + + $variable = $this->variableNameExpression ? $context->evaluate($this->variableNameExpression) : null; + + if (! $this->isForLoop) { + return $partial->render($this->buildPartialContext($context, $templateName, [ + $contextVariableName => $variable, + ])); + } + + $variable = $this->resolveLoopValues($variable); + + $forLoop = new ForLoopDrop($templateName, count($variable)); $output = ''; - foreach ($this->stream($context) as $chunk) { - $output .= $chunk; + foreach ($variable as $value) { + $output .= $partial->render($this->buildPartialContext($context, $templateName, [ + 'forloop' => $forLoop, + $contextVariableName => $value, + ])); + + $forLoop->increment(); } return $output; @@ -118,14 +140,13 @@ public function stream(RenderContext $context): \Generator $partial = $this->loadPartial($context); $templateName = $partial->name() ?? ''; - $contextVariableName = $this->aliasName ?? Arr::last(explode('/', $templateName)); + $contextVariableName = ($this->aliasName ?? Arr::last(explode('/', $templateName))); assert(is_string($contextVariableName)); $variable = $this->variableNameExpression ? $context->evaluate($this->variableNameExpression) : null; if ($this->isForLoop) { - $variable = $variable instanceof Traversable ? iterator_to_array($variable) : $variable; - assert(is_array($variable)); + $variable = $this->resolveLoopValues($variable); $forLoop = new ForLoopDrop($templateName, count($variable)); @@ -173,6 +194,18 @@ protected function loadPartial(RenderContext $context): Template return $context->loadPartial($templateName); } + /** + * @return array + */ + private function resolveLoopValues(mixed $variable): array + { + if (! is_iterable($variable)) { + throw new InvalidArgumentException('Invalid array'); + } + + return iterator_to_array($variable); + } + protected function buildPartialContext(RenderContext $rootContext, string $templateName, array $variables = []): RenderContext { $partialContext = $rootContext->newIsolatedSubContext($templateName); From d2e85e22dd86e2a9824796ce23c90b941dcbe174 Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Tue, 4 Aug 2026 11:23:15 +0200 Subject: [PATCH 06/11] Cache parsed condition operators --- performance/benchmarks/OperationBench.php | 11 +++++++++++ src/Condition/Condition.php | 4 +++- tests/Unit/ConditionTest.php | 12 ++++++++++++ 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/performance/benchmarks/OperationBench.php b/performance/benchmarks/OperationBench.php index 2c89c44..8532fef 100644 --- a/performance/benchmarks/OperationBench.php +++ b/performance/benchmarks/OperationBench.php @@ -41,6 +41,8 @@ class OperationBench private Template $productListTemplate; + private Template $conditionTemplate; + /** * Built in setUp, not in the subject: these two subjects measure how * Drop::__get resolves a property, and constructing a ProductDrop (variants, @@ -64,6 +66,7 @@ public function setUp(): void $this->dropMethodMissingHitTemplate = $this->environment->parseString(str_repeat('{{ product.metafields.material }}', 64)); $this->dropMethodMissingMissTemplate = $this->environment->parseString(str_repeat('{{ product.metafields.unknown }}', 64)); $this->productListTemplate = $this->environment->parseString('{% for product in products %}{{ product.title }}{% endfor %}'); + $this->conditionTemplate = $this->environment->parseString(str_repeat('{% if value == expected %}x{% endif %}', 64)); $this->productDrop = Database::product(); $this->productList = Database::products(); } @@ -164,6 +167,14 @@ public function benchFilterWithArguments(): void $this->filterWithArgumentsTemplate->render($this->filterContext()); } + public function benchConditionRender(): void + { + $this->conditionTemplate->render($this->environment->newRenderContext(staticData: [ + 'value' => 'value', + 'expected' => 'value', + ])); + } + /** * @param \Generator $stream */ diff --git a/src/Condition/Condition.php b/src/Condition/Condition.php index 0953311..ef74460 100644 --- a/src/Condition/Condition.php +++ b/src/Condition/Condition.php @@ -19,6 +19,8 @@ class Condition implements HasParseTreeVisitorChildren protected ?Condition $childCondition = null; + protected ?ConditionOperator $parsedOperator = null; + public ?BodyNode $body = null; public function __construct( @@ -110,7 +112,7 @@ protected function interpretCondition(mixed $left, mixed $right, ?string $operat return (bool) static::$customOperators[$operator]($left, $right); } - return ConditionOperator::parse($operator)->evaluate($left, $right); + return ($this->parsedOperator ??= ConditionOperator::parse($operator))->evaluate($left, $right); } protected function toLiquidValue(mixed $value): mixed diff --git a/tests/Unit/ConditionTest.php b/tests/Unit/ConditionTest.php index 5cc7a9a..cb3a632 100644 --- a/tests/Unit/ConditionTest.php +++ b/tests/Unit/ConditionTest.php @@ -135,6 +135,18 @@ expect((new Condition('bob', 'starts_with', 'o'))->evaluate($this->context))->toBeFalse(); }); +test('custom operators override cached built-in operators', function () { + $condition = new Condition(1, '==', 1); + + expect($condition->evaluate($this->context))->toBeTrue(); + + Condition::registerOperator('==', fn (mixed $left, mixed $right) => false); + expect($condition->evaluate($this->context))->toBeFalse(); + + Condition::deleteOperator('=='); + expect($condition->evaluate($this->context))->toBeTrue(); +}); + test('compare two variable', function () { $this->context->set('one', 'gnomeslab-and-or-liquid'); $this->context->set('another', 'gnomeslab-and-or-liquid'); From e5967d0b57127c345e6885a1fd4fb28c328df688 Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Tue, 4 Aug 2026 11:42:08 +0200 Subject: [PATCH 07/11] Optimize assignment render hot paths --- performance/benchmarks/OperationBench.php | 36 +++++++++++++++++++++++ src/Render/RenderContext.php | 7 ++--- src/Tags/AssignTag.php | 27 ++++++++++++----- tests/Integration/Tags/AssignTagTest.php | 7 +++++ 4 files changed, 66 insertions(+), 11 deletions(-) diff --git a/performance/benchmarks/OperationBench.php b/performance/benchmarks/OperationBench.php index 8532fef..6d7cb0b 100644 --- a/performance/benchmarks/OperationBench.php +++ b/performance/benchmarks/OperationBench.php @@ -43,6 +43,15 @@ class OperationBench private Template $conditionTemplate; + private Template $assignTemplate; + + private Template $assignCompositeTemplate; + + private Template $captureTemplate; + + /** @var array */ + private array $assignCompositeData; + /** * Built in setUp, not in the subject: these two subjects measure how * Drop::__get resolves a property, and constructing a ProductDrop (variants, @@ -67,6 +76,14 @@ public function setUp(): void $this->dropMethodMissingMissTemplate = $this->environment->parseString(str_repeat('{{ product.metafields.unknown }}', 64)); $this->productListTemplate = $this->environment->parseString('{% for product in products %}{{ product.title }}{% endfor %}'); $this->conditionTemplate = $this->environment->parseString(str_repeat('{% if value == expected %}x{% endif %}', 64)); + $this->assignTemplate = $this->environment->parseString(str_repeat('{% assign value = value %}', 64)); + $this->assignCompositeTemplate = $this->environment->parseString(str_repeat('{% assign value = values %}', 16)); + $this->captureTemplate = $this->environment->parseString(str_repeat('{% capture value %}captured{% endcapture %}', 64)); + $this->assignCompositeData = [ + 'title' => 'Product title', + 'tags' => ['one', 'two', 'three'], + 'metadata' => ['material' => 'cotton', 'color' => 'blue'], + ]; $this->productDrop = Database::product(); $this->productList = Database::products(); } @@ -175,6 +192,25 @@ public function benchConditionRender(): void ])); } + public function benchAssignRender(): void + { + $this->assignTemplate->render($this->environment->newRenderContext(staticData: [ + 'value' => 'value', + ])); + } + + public function benchAssignCompositeRender(): void + { + $this->assignCompositeTemplate->render($this->environment->newRenderContext(staticData: [ + 'values' => $this->assignCompositeData, + ])); + } + + public function benchCaptureRender(): void + { + $this->captureTemplate->render($this->environment->newRenderContext()); + } + /** * @param \Generator $stream */ diff --git a/src/Render/RenderContext.php b/src/Render/RenderContext.php index 9a48002..4520aee 100644 --- a/src/Render/RenderContext.php +++ b/src/Render/RenderContext.php @@ -386,10 +386,9 @@ public function setToActiveScope(string $key, mixed $value): array { $index = count($this->scopes) - 1; - return $this->scopes[$index] = [ - ...$this->scopes[$index], - $key => $value, - ]; + $this->scopes[$index][$key] = $value; + + return $this->scopes[$index]; } public function pushInterrupt(Interrupt $interrupt): void diff --git a/src/Tags/AssignTag.php b/src/Tags/AssignTag.php index 647e6cc..612d640 100644 --- a/src/Tags/AssignTag.php +++ b/src/Tags/AssignTag.php @@ -9,7 +9,6 @@ use Keepsuit\Liquid\Parse\TagParseContext; use Keepsuit\Liquid\Parse\TokenType; use Keepsuit\Liquid\Render\RenderContext; -use Keepsuit\Liquid\Support\Arr; use Keepsuit\Liquid\Tag; /** @@ -65,11 +64,25 @@ public function parseTreeVisitorChildren(): array protected static function computeAssignScore(mixed $value): int { - return match (true) { - is_string($value) => strlen($value), - is_array($value) && array_is_list($value) => 1 + (int) array_sum(Arr::map($value, fn (mixed $item) => static::computeAssignScore($item))), - is_array($value) => 1 + (int) array_sum(Arr::map($value, fn (mixed $key, mixed $item) => static::computeAssignScore($key) + static::computeAssignScore($item))), - default => 1, - }; + if (is_string($value)) { + return strlen($value); + } + + if (! is_array($value)) { + return 1; + } + + $score = 1; + $isList = array_is_list($value); + + foreach ($value as $key => $item) { + if (! $isList) { + $score += static::computeAssignScore($key); + } + + $score += static::computeAssignScore($item); + } + + return $score; } } diff --git a/tests/Integration/Tags/AssignTagTest.php b/tests/Integration/Tags/AssignTagTest.php index c277b1f..8ed0335 100644 --- a/tests/Integration/Tags/AssignTagTest.php +++ b/tests/Integration/Tags/AssignTagTest.php @@ -30,6 +30,13 @@ ); }); +test('assign preserves values already stored in the active scope', function () { + assertTemplateResult( + 'first-second', + '{% assign first = "first" %}{% assign second = "second" %}{{ first }}-{{ second }}', + ); +}); + test('assigned with filter', function () { assertTemplateResult( '.bar.', From e2852d881de7cf8a188ebe4a5226809f0f175713 Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Tue, 4 Aug 2026 12:44:00 +0200 Subject: [PATCH 08/11] Enforce output limits at template boundaries - Track streamed output across chunks and reset per root stream - Charge captured values to the assign score - Cover partials, custom nodes, and zero-valued limits --- src/Nodes/BodyNode.php | 16 ++----- src/Nodes/Document.php | 2 + src/Render/ResourceLimits.php | 68 +++++++++++++++++++++--------- src/Tags/CaptureTag.php | 6 +-- src/Template.php | 30 ++++++++++++- tests/Integration/StreamTest.php | 56 ++++++++++++++++++++++++ tests/Integration/TemplateTest.php | 37 ++++++++++++++++ tests/Stubs/StreamingTag.php | 36 ++++++++++++++++ tests/Unit/ResourceLimitsTest.php | 39 +++++++++++++++++ 9 files changed, 252 insertions(+), 38 deletions(-) create mode 100644 tests/Stubs/StreamingTag.php diff --git a/src/Nodes/BodyNode.php b/src/Nodes/BodyNode.php index 3cfde82..1c20425 100644 --- a/src/Nodes/BodyNode.php +++ b/src/Nodes/BodyNode.php @@ -77,8 +77,6 @@ public function render(RenderContext $context): string } } - $context->resourceLimits->incrementWriteScore($output); - return $output; } @@ -94,7 +92,6 @@ public function stream(RenderContext $context): \Generator foreach ($this->children as $node) { // Text is the majority of children and cannot fail or interrupt. if ($node instanceof Text) { - $context->resourceLimits->incrementWriteScore($node->value); yield $node->value; continue; @@ -106,21 +103,14 @@ public function stream(RenderContext $context): \Generator } if ($node instanceof CanBeStreamed) { - foreach ($node->stream($context) as $output) { - $context->resourceLimits->incrementWriteScore($output); - yield $output; - } + yield from $node->stream($context); } else { - $output = $node->render($context); - $context->resourceLimits->incrementWriteScore($output); - yield $output; + yield $node->render($context); } } catch (UndefinedVariableException|UndefinedDropMethodException|UndefinedFilterException $exception) { $context->handleError($exception, $node->lineNumber); } catch (\Throwable $exception) { - $output = $context->handleError($exception, $node->lineNumber); - $context->resourceLimits->incrementWriteScore($output); - yield $output; + yield $context->handleError($exception, $node->lineNumber); } if ($context->hasInterrupt()) { diff --git a/src/Nodes/Document.php b/src/Nodes/Document.php index 5ef9ef6..0b6d195 100644 --- a/src/Nodes/Document.php +++ b/src/Nodes/Document.php @@ -22,6 +22,8 @@ public function render(RenderContext $context): string } /** + * @return \Generator + * * @throws LiquidException */ public function stream(RenderContext $context): \Generator diff --git a/src/Render/ResourceLimits.php b/src/Render/ResourceLimits.php index 1efc0ac..09dab39 100644 --- a/src/Render/ResourceLimits.php +++ b/src/Render/ResourceLimits.php @@ -15,7 +15,7 @@ class ResourceLimits protected int $cumulativeAssignScore = 0; - protected ?int $lastCaptureLength = null; + protected int $streamedLength = 0; protected bool $reachedLimit = false; @@ -46,11 +46,11 @@ public function incrementRenderScore(int $amount = 1): ResourceLimits $this->renderScore += $amount; $this->cumulativeRenderScore += $amount; - if ($this->renderScoreLimit != null && $this->renderScoreLimit < $this->renderScore) { + if ($this->renderScoreLimit !== null && $this->renderScoreLimit < $this->renderScore) { $this->throwLimitReachedException(); } - if ($this->cumulativeRenderScoreLimit != null && $this->cumulativeRenderScoreLimit < $this->cumulativeRenderScore) { + if ($this->cumulativeRenderScoreLimit !== null && $this->cumulativeRenderScoreLimit < $this->cumulativeRenderScore) { $this->throwLimitReachedException(); } @@ -65,11 +65,11 @@ public function incrementAssignScore(int $amount = 1): ResourceLimits $this->assignScore += $amount; $this->cumulativeAssignScore += $amount; - if ($this->assignScoreLimit != null && $this->assignScoreLimit < $this->assignScore) { + if ($this->assignScoreLimit !== null && $this->assignScoreLimit < $this->assignScore) { $this->throwLimitReachedException(); } - if ($this->cumulativeAssignScoreLimit != null && $this->cumulativeAssignScoreLimit < $this->cumulativeAssignScore) { + if ($this->cumulativeAssignScoreLimit !== null && $this->cumulativeAssignScoreLimit < $this->cumulativeAssignScore) { $this->throwLimitReachedException(); } @@ -77,31 +77,59 @@ public function incrementAssignScore(int $amount = 1): ResourceLimits } /** + * Called from exactly one place, Template::render() on the root template. + * The root output already contains every node's output, including output + * from nodes defined outside this library, so nested bodies have nothing + * to add and custom nodes have nothing to remember. + * * @throws ResourceLimitException */ public function incrementWriteScore(string $output): ResourceLimits { - if (($lastCaptured = $this->lastCaptureLength) !== null) { - $captured = strlen($output); - $increment = $captured - $lastCaptured; - $this->lastCaptureLength = $captured; - $this->incrementAssignScore($increment); + if ($this->renderLengthLimit !== null && strlen($output) > $this->renderLengthLimit) { + $this->throwLimitReachedException(); + } + + return $this; + } - return $this; + /** + * Streaming counterpart of incrementWriteScore: chunks arrive one at a time, + * so the length limit has to be checked against a running total instead of + * the length of a single chunk. + * + * Called from exactly one place, Template::stream() on the root template, + * for the same reason incrementWriteScore is. + * + * The total is reset per root stream by resetStreamWriteScore(), so the + * limit caps one stream rather than the lifetime of the context — the same + * scope the render path applies to a single render() call. + * + * @throws ResourceLimitException + */ + public function incrementStreamWriteScore(string $output): void + { + if ($this->renderLengthLimit === null) { + return; } - if ($this->renderLengthLimit !== null && strlen($output) > $this->renderLengthLimit) { + $this->streamedLength += strlen($output); + + if ($this->streamedLength > $this->renderLengthLimit) { $this->throwLimitReachedException(); } + } - return $this; + public function resetStreamWriteScore(): void + { + $this->streamedLength = 0; } public function reset(): ResourceLimits { $this->renderScore = 0; + $this->streamedLength = 0; $this->assignScore = 0; - $this->lastCaptureLength = null; $this->reachedLimit = false; return $this; @@ -139,15 +167,17 @@ public function getCumulativeRenderScore(): int return $this->cumulativeRenderScore; } + /** + * @param Closure(): string $closure + * @return string + * + * @throws ResourceLimitException + */ public function withCapture(Closure $closure): mixed { - $oldCaptureLength = $this->lastCaptureLength; - - $this->lastCaptureLength = 0; - $result = $closure(); - $this->lastCaptureLength = $oldCaptureLength; + $this->incrementAssignScore(strlen($result)); return $result; } diff --git a/src/Tags/CaptureTag.php b/src/Tags/CaptureTag.php index 1e5fa30..6c0933a 100644 --- a/src/Tags/CaptureTag.php +++ b/src/Tags/CaptureTag.php @@ -43,11 +43,9 @@ public function blank(): bool public function render(RenderContext $context): string { - $context->resourceLimits->withCapture(function () use ($context) { - $captureValue = $this->body->render($context); + $captureValue = $context->resourceLimits->withCapture(fn () => $this->body->render($context)); - $context->setToActiveScope($this->to, $captureValue); - }); + $context->setToActiveScope($this->to, $captureValue); return ''; } diff --git a/src/Template.php b/src/Template.php index 4a553e2..10fc16d 100644 --- a/src/Template.php +++ b/src/Template.php @@ -21,7 +21,14 @@ public function render(RenderContext $context): string try { $context->mergeOutputs($this->state->outputs); - return $this->root->render($context); + $output = $this->root->render($context); + + // Partials are already part of the root output + if (! $context->isPartial()) { + $context->resourceLimits->incrementWriteScore($output); + } + + return $output; } catch (LiquidException $e) { $e->templateName = $e->templateName ?? $this->root->name; throw $e; @@ -39,7 +46,26 @@ public function stream(RenderContext $context): \Generator try { $context->mergeOutputs($this->state->outputs); - yield from $this->root->stream($context); + // Partials are streamed through the root template's loop below + if ($context->isPartial()) { + yield from $this->root->stream($context); + + return; + } + + /* + * The one place every chunk is guaranteed to pass through exactly once, + * whichever node produced it. Two jobs happen here: + * - increment the write score and checked against a running total + * - renumbering the keys, since nodes delegate with `yield from`, which passes the inner generators' keys through and restarts them at 0 + */ + $context->resourceLimits->resetStreamWriteScore(); + + foreach ($this->root->stream($context) as $output) { + $context->resourceLimits->incrementStreamWriteScore($output); + + yield $output; + } } catch (LiquidException $e) { $e->templateName = $e->templateName ?? $this->root->name; throw $e; diff --git a/tests/Integration/StreamTest.php b/tests/Integration/StreamTest.php index b7f5882..84a0738 100644 --- a/tests/Integration/StreamTest.php +++ b/tests/Integration/StreamTest.php @@ -99,6 +99,62 @@ ]); }); +test('streaming enforces the render length limit across chunks', function () { + $environment = \Keepsuit\Liquid\Environment::default(); + $template = $environment->parseString('{% for i in (1..6) %}{{ i }}{% endfor %}'); + + $stream = $template->stream($environment->newRenderContext( + resourceLimits: new \Keepsuit\Liquid\Render\ResourceLimits(renderLengthLimit: 5), + )); + + expect(fn () => iterator_to_array($stream)) + ->toThrow(\Keepsuit\Liquid\Exceptions\ResourceLimitException::class); + + $stream = $template->stream($environment->newRenderContext( + resourceLimits: new \Keepsuit\Liquid\Render\ResourceLimits(renderLengthLimit: 6), + )); + + expect(implode('', iterator_to_array($stream)))->toBe('123456'); +}); + +test('the render length limit covers chunks from partials and custom nodes', function () { + $environment = \Keepsuit\Liquid\EnvironmentFactory::new() + ->setFilesystem(new \Keepsuit\Liquid\Tests\Stubs\StubFileSystem(partials: ['snippet' => '{% streaming %}'])) + ->build(); + $environment->tagRegistry->register(\Keepsuit\Liquid\Tests\Stubs\StreamingTag::class); + + // The tag lives outside the library and yields its own chunks, nested one + // partial deep: neither the tag nor the partial counts anything itself. + $template = $environment->parseString('{% render "snippet" %}'); + + $stream = $template->stream($environment->newRenderContext( + resourceLimits: new \Keepsuit\Liquid\Render\ResourceLimits(renderLengthLimit: 5), + )); + + expect(fn () => iterator_to_array($stream)) + ->toThrow(\Keepsuit\Liquid\Exceptions\ResourceLimitException::class); + + $stream = $template->stream($environment->newRenderContext( + resourceLimits: new \Keepsuit\Liquid\Render\ResourceLimits(renderLengthLimit: 6), + )); + + expect(implode('', iterator_to_array($stream)))->toBe('abcdef'); +}); + +test('the render length limit caps one stream, not the context lifetime', function () { + $environment = \Keepsuit\Liquid\Environment::default(); + $template = $environment->parseString('abcdefgh'); + $context = $environment->newRenderContext( + resourceLimits: new \Keepsuit\Liquid\Render\ResourceLimits(renderLengthLimit: 10), + ); + + // Same scope the render path applies to a single render() call: streaming + // twice through one context must not add up to the limit. + expect(implode('', iterator_to_array($template->stream($context))))->toBe('abcdefgh'); + expect(implode('', iterator_to_array($template->stream($context))))->toBe('abcdefgh'); + expect($template->render($context))->toBe('abcdefgh'); +}); + test('streamed for tags preserve break and continue behavior', function () { $environment = \Keepsuit\Liquid\Environment::default(); $template = $environment->parseString(<<<'LIQUID' diff --git a/tests/Integration/TemplateTest.php b/tests/Integration/TemplateTest.php index 29b0620..73c6aeb 100644 --- a/tests/Integration/TemplateTest.php +++ b/tests/Integration/TemplateTest.php @@ -58,6 +58,43 @@ expect($context->resourceLimits->reached())->toBeFalse(); }); +test('render length limit covers output from partials and custom nodes', function () { + $environment = EnvironmentFactory::new() + ->setFilesystem(new StubFileSystem(partials: ['snippet' => '{% streaming %}'])) + ->build(); + $environment->tagRegistry->register(\Keepsuit\Liquid\Tests\Stubs\StreamingTag::class); + + // Checked once on the root output, so a node outside the library nested in + // a partial is covered without either of them counting anything. + $template = $environment->parseString('{% render "snippet" %}'); + + $context = $environment->newRenderContext(resourceLimits: new ResourceLimits(renderLengthLimit: 5)); + expect(fn () => $template->render($context))->toThrow(ResourceLimitException::class); + + $context = $environment->newRenderContext(resourceLimits: new ResourceLimits(renderLengthLimit: 6)); + expect($template->render($context))->toBe('abcdef'); +}); + +test('capture charges the captured length to the assign score', function () { + // Several sibling bodies of differing lengths, so this pins the total to + // the captured string rather than to any one body inside it. + $context = new RenderContext; + parseTemplate('{% capture x %}{% if true %}aaaaaa{% endif %}{% if true %}b{% endif %}{% endcapture %}') + ->render($context); + expect($context->resourceLimits->getAssignScore())->toBe(7); + + // Nested captures are two variables holding 4 bytes each, so they charge + // the same 8 as the two equivalent assigns below. + $context = new RenderContext; + parseTemplate('{% capture outer %}{% capture inner %}abcd{% endcapture %}{{ inner }}{% endcapture %}') + ->render($context); + expect($context->resourceLimits->getAssignScore())->toBe(8); + + $context = new RenderContext; + parseTemplate('{% assign a = "abcd" %}{% assign b = a %}')->render($context); + expect($context->resourceLimits->getAssignScore())->toBe(8); +}); + test('resource limits render score', function () { $template = parseTemplate('{% for a in (1..10) %} {% for a in (1..10) %} foo {% endfor %} {% endfor %}'); $context = new RenderContext( diff --git a/tests/Stubs/StreamingTag.php b/tests/Stubs/StreamingTag.php new file mode 100644 index 0000000..7e970f6 --- /dev/null +++ b/tests/Stubs/StreamingTag.php @@ -0,0 +1,36 @@ +and($limits->getCumulativeAssignScore())->toBe(3); }); +test('a zero limit rejects everything instead of disabling the limit', function () { + expect(fn () => (new ResourceLimits(renderScoreLimit: 0))->incrementRenderScore(1)) + ->toThrow(ResourceLimitException::class); + expect(fn () => (new ResourceLimits(assignScoreLimit: 0))->incrementAssignScore(1)) + ->toThrow(ResourceLimitException::class); + expect(fn () => (new ResourceLimits(cumulativeRenderScoreLimit: 0))->incrementRenderScore(1)) + ->toThrow(ResourceLimitException::class); + expect(fn () => (new ResourceLimits(cumulativeAssignScoreLimit: 0))->incrementAssignScore(1)) + ->toThrow(ResourceLimitException::class); +}); + +test('withCapture charges the returned string to the assign score', function () { + $limits = new ResourceLimits; + + expect($limits->withCapture(fn () => 'abcd'))->toBe('abcd') + ->and($limits->getAssignScore())->toBe(4); + + // Nested, as {% capture %} inside {% capture %}: two variables, both charged. + $limits = new ResourceLimits; + $limits->withCapture(fn () => $limits->withCapture(fn () => 'abcd')); + expect($limits->getAssignScore())->toBe(8); + + // The length is read off the returned string, so a closure that captures + // without returning the text is charged nothing. + $limits = new ResourceLimits; + $limits->withCapture(fn () => null); + expect($limits->getAssignScore())->toBe(0); +}); + +test('withCapture does not charge the render length limit', function () { + // Captured text is assigned, not written, so only the assign limit applies. + $limits = new ResourceLimits(renderLengthLimit: 2); + expect($limits->withCapture(fn () => 'abcd'))->toBe('abcd'); + + $limits = new ResourceLimits(assignScoreLimit: 2); + expect(fn () => $limits->withCapture(fn () => 'abcd')) + ->toThrow(ResourceLimitException::class); +}); + test('resource limits cumulative render score limit', function () { $limits = new ResourceLimits(cumulativeRenderScoreLimit: 3); From 96565ffb6ad94c4f8d822931b7fe3c3d8fd27692 Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Tue, 4 Aug 2026 14:26:30 +0200 Subject: [PATCH 09/11] fix --- src/Render/ResourceLimits.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Render/ResourceLimits.php b/src/Render/ResourceLimits.php index 09dab39..ac3c9a7 100644 --- a/src/Render/ResourceLimits.php +++ b/src/Render/ResourceLimits.php @@ -177,7 +177,9 @@ public function withCapture(Closure $closure): mixed { $result = $closure(); - $this->incrementAssignScore(strlen($result)); + if (is_string($result)) { + $this->incrementAssignScore(strlen($result)); + } return $result; } From 97a99d24787d5845de541e7a4f20b48a7061e29d Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Tue, 4 Aug 2026 15:34:02 +0200 Subject: [PATCH 10/11] Buffer streamed output at template boundaries - Flush buffered output before rethrowing errors - Preserve streaming behavior with threshold coverage --- src/Template.php | 30 +++++- tests/Integration/StreamTest.php | 130 +++++++++++++++++------ tests/Integration/Tags/RenderTagTest.php | 9 +- 3 files changed, 128 insertions(+), 41 deletions(-) diff --git a/src/Template.php b/src/Template.php index 10fc16d..583ecbe 100644 --- a/src/Template.php +++ b/src/Template.php @@ -8,6 +8,8 @@ class Template { + private const MAX_BUFFERED_BYTES = 4096; + public function __construct( public readonly Document $root, public readonly TemplateSharedState $state = new TemplateSharedState @@ -43,6 +45,8 @@ public function render(RenderContext $context): string */ public function stream(RenderContext $context): \Generator { + $buffer = ''; + try { $context->mergeOutputs($this->state->outputs); @@ -55,8 +59,9 @@ public function stream(RenderContext $context): \Generator /* * The one place every chunk is guaranteed to pass through exactly once, - * whichever node produced it. Two jobs happen here: - * - increment the write score and checked against a running total + * whichever node produced it. Three jobs happen here: + * - increment the write score, checked against a running total. + * - Buffer streamed output in larger chunks. * - renumbering the keys, since nodes delegate with `yield from`, which passes the inner generators' keys through and restarts them at 0 */ $context->resourceLimits->resetStreamWriteScore(); @@ -64,10 +69,29 @@ public function stream(RenderContext $context): \Generator foreach ($this->root->stream($context) as $output) { $context->resourceLimits->incrementStreamWriteScore($output); - yield $output; + $buffer .= $output; + + if (strlen($buffer) >= self::MAX_BUFFERED_BYTES) { + yield $buffer; + $buffer = ''; + } + } + + if ($buffer !== '') { + yield $buffer; } } catch (LiquidException $e) { + if ($buffer !== '') { + yield $buffer; + } + $e->templateName = $e->templateName ?? $this->root->name; + throw $e; + } catch (\Throwable $e) { + if ($buffer !== '') { + yield $buffer; + } + throw $e; } finally { $this->state->errors = $context->getErrors(); diff --git a/tests/Integration/StreamTest.php b/tests/Integration/StreamTest.php index 84a0738..374c0df 100644 --- a/tests/Integration/StreamTest.php +++ b/tests/Integration/StreamTest.php @@ -11,25 +11,21 @@ $output = iterator_to_array($stream); - expect($output)->toBe([ - "text\n", - '1', - "\n", - '2', - "\n", - '3', - "\n", - ]); + // Node chunks are grouped, so a template this small arrives in one piece. + expect($output)->toBe(["text\n1\n2\n3\n"]); }); test('stream generator variable', function () { + // Values sized to the grouping threshold, so consuming the generator one + // value at a time stays visible: a materialised value could only arrive as + // a single chunk. $stream = streamTemplate(<<<'LIQUID' {{ var }} LIQUID, staticData: [ 'var' => function () { - yield 'text1'; - yield 'text2'; + yield str_repeat('a', 4096); + yield str_repeat('b', 4096); }, ] ); @@ -38,18 +34,20 @@ expect($output) ->toHaveCount(2) - ->{0}->toBe('text1') - ->{1}->toBe('text2'); + ->{0}->toBe(str_repeat('a', 4096)) + ->{1}->toBe(str_repeat('b', 4096)); }); test('generator variable with filters is not streamed', function () { + // The same values as above, but a filter has to materialise the generator + // before it can run, so all of it is produced at once. $stream = streamTemplate(<<<'LIQUID' {{ var | join: ',' }} LIQUID, staticData: [ 'var' => function () { - yield 'text1'; - yield 'text2'; + yield str_repeat('a', 4096); + yield str_repeat('b', 4096); }, ] ); @@ -58,7 +56,7 @@ expect($output) ->toHaveCount(1) - ->{0}->toBe('text1,text2'); + ->{0}->toBe(str_repeat('a', 4096).','.str_repeat('b', 4096)); }); test('for tags stream their body chunks', function () { @@ -67,14 +65,18 @@ staticData: ['items' => ['a', 'bb']], ); - expect(iterator_to_array($stream))->toBe([ - '', - 'a', - '', - '', - 'bb', - '', - ]); + expect(iterator_to_array($stream))->toBe(['abb']); +}); + +test('a loop larger than the grouping threshold keeps output flowing', function () { + $stream = streamTemplate( + '{% for item in items %}{{ item }}{% endfor %}', + staticData: ['items' => array_fill(0, 8, str_repeat('x', 1024))], + ); + + // 8 KB cannot arrive as one chunk: output is emitted while the loop is + // still running, which is what bounds memory on a large template. + expect(iterator_to_array($stream))->toHaveCount(2); }); test('if, unless, and case tags stream their selected body chunks', function () { @@ -89,14 +91,7 @@ ], ); - expect(iterator_to_array($stream))->toBe([ - 'if:', - 'x', - 'unless:', - 'x', - 'case:', - 'x', - ]); + expect(iterator_to_array($stream))->toBe(['if:xunless:xcase:x']); }); test('streaming enforces the render length limit across chunks', function () { @@ -155,6 +150,77 @@ expect($template->render($context))->toBe('abcdefgh'); }); +test('grouped output reaches the consumer before a rethrown error', function () { + $environment = \Keepsuit\Liquid\EnvironmentFactory::new()->setRethrowErrors(true)->build(); + $template = $environment->parseString('HELLO WORLD {{ boom.standard_error }} tail'); + + $context = $environment->newRenderContext(staticData: [ + 'boom' => new \Keepsuit\Liquid\Tests\Stubs\ErrorDrop, + ]); + + $received = []; + try { + foreach ($template->stream($context) as $chunk) { + $received[] = $chunk; + } + } catch (\Throwable) { + // the error is expected; what matters is what arrived before it + } + + // Output already produced must not be discarded along with the exception. + expect(implode('', $received))->toBe('HELLO WORLD '); +}); + +test('grouped output survives an error handler throwing a non liquid exception', function () { + // A custom handler can throw anything, so what escapes the stream is not + // always a LiquidException — the buffer still has to be flushed. + $handler = new class implements \Keepsuit\Liquid\Contracts\LiquidErrorHandler + { + public function handle(\Throwable $error): string + { + throw new \RuntimeException('from handler'); + } + }; + + $environment = \Keepsuit\Liquid\EnvironmentFactory::new() + ->setErrorHandler($handler) + ->setRethrowErrors(false) + ->build(); + $template = $environment->parseString('PREFIX {{ boom.standard_error }} tail'); + + $context = $environment->newRenderContext(staticData: [ + 'boom' => new \Keepsuit\Liquid\Tests\Stubs\ErrorDrop, + ]); + + $received = []; + expect(function () use ($template, $context, &$received) { + foreach ($template->stream($context) as $chunk) { + $received[] = $chunk; + } + })->toThrow(RuntimeException::class); + + expect(implode('', $received))->toBe('PREFIX '); +}); + +test('a consumer can stop reading a stream part way through', function () { + $environment = \Keepsuit\Liquid\Environment::default(); + $template = $environment->parseString('{% for i in items %}{{ i }}{% endfor %}'); + + $context = $environment->newRenderContext(staticData: [ + 'items' => array_fill(0, 8, str_repeat('x', 1024)), + ]); + + // Abandoning the generator force-closes it, so the pending buffer must not + // be flushed from a finally block: yielding from one there is fatal. + $first = null; + foreach ($template->stream($context) as $chunk) { + $first = $chunk; + break; + } + + expect($first)->toBe(str_repeat('x', 4096)); +}); + test('streamed for tags preserve break and continue behavior', function () { $environment = \Keepsuit\Liquid\Environment::default(); $template = $environment->parseString(<<<'LIQUID' diff --git a/tests/Integration/Tags/RenderTagTest.php b/tests/Integration/Tags/RenderTagTest.php index a0e3068..00ea678 100644 --- a/tests/Integration/Tags/RenderTagTest.php +++ b/tests/Integration/Tags/RenderTagTest.php @@ -341,13 +341,10 @@ public function get(string $name): ?Template $output = iterator_to_array($stream); + // Both rendered items fit under the grouping threshold, so they arrive + // together rather than one chunk per node. expect($output) ->toBe([ - 'Product: ', - 'Draft 151cm', - ' ', - 'Product: ', - 'Element 155cm', - ' ', + 'Product: Draft 151cm Product: Element 155cm ', ]); }); From 3c04b6f39858a2b495f2c391105b6673e5c0452f Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Tue, 4 Aug 2026 16:30:37 +0200 Subject: [PATCH 11/11] Buffer streamed output at body boundaries - Move chunk buffering into BodyNode streaming - Preserve template-level resource-limit accounting --- src/Nodes/BodyNode.php | 38 ++++++++++++++++++++++++++++++++++---- src/Template.php | 29 ++--------------------------- 2 files changed, 36 insertions(+), 31 deletions(-) diff --git a/src/Nodes/BodyNode.php b/src/Nodes/BodyNode.php index 1c20425..711b427 100644 --- a/src/Nodes/BodyNode.php +++ b/src/Nodes/BodyNode.php @@ -13,6 +13,8 @@ class BodyNode extends Node implements CanBeStreamed { + private const MAX_BUFFERED_BYTES = 4096; + public function __construct( /** @var array */ protected array $children = [], @@ -89,34 +91,62 @@ public function stream(RenderContext $context): \Generator { $context->resourceLimits->incrementRenderScore(count($this->children)); + $buffer = ''; + foreach ($this->children as $node) { // Text is the majority of children and cannot fail or interrupt. if ($node instanceof Text) { - yield $node->value; + $buffer .= $node->value; continue; } + if (strlen($buffer) >= self::MAX_BUFFERED_BYTES) { + yield $buffer; + $buffer = ''; + } + try { if ($node instanceof Disableable && $node instanceof Tag) { $node->ensureTagIsEnabled($context); } if ($node instanceof CanBeStreamed) { - yield from $node->stream($context); + foreach ($node->stream($context) as $output) { + $buffer .= $output; + + if (strlen($buffer) >= self::MAX_BUFFERED_BYTES) { + yield $buffer; + $buffer = ''; + } + } } else { - yield $node->render($context); + $buffer .= $node->render($context); } } catch (UndefinedVariableException|UndefinedDropMethodException|UndefinedFilterException $exception) { + if ($buffer !== '') { + yield $buffer; + $buffer = ''; + } + $context->handleError($exception, $node->lineNumber); } catch (\Throwable $exception) { - yield $context->handleError($exception, $node->lineNumber); + if ($buffer !== '') { + yield $buffer; + $buffer = ''; + } + + $buffer .= $context->handleError($exception, $node->lineNumber); } if ($context->hasInterrupt()) { break; } } + + if ($buffer !== '') { + yield $buffer; + } } public function blank(): bool diff --git a/src/Template.php b/src/Template.php index 583ecbe..a13af6c 100644 --- a/src/Template.php +++ b/src/Template.php @@ -8,8 +8,6 @@ class Template { - private const MAX_BUFFERED_BYTES = 4096; - public function __construct( public readonly Document $root, public readonly TemplateSharedState $state = new TemplateSharedState @@ -45,8 +43,6 @@ public function render(RenderContext $context): string */ public function stream(RenderContext $context): \Generator { - $buffer = ''; - try { $context->mergeOutputs($this->state->outputs); @@ -59,39 +55,18 @@ public function stream(RenderContext $context): \Generator /* * The one place every chunk is guaranteed to pass through exactly once, - * whichever node produced it. Three jobs happen here: + * whichever node produced it. Two jobs happen here: * - increment the write score, checked against a running total. - * - Buffer streamed output in larger chunks. * - renumbering the keys, since nodes delegate with `yield from`, which passes the inner generators' keys through and restarts them at 0 */ $context->resourceLimits->resetStreamWriteScore(); foreach ($this->root->stream($context) as $output) { $context->resourceLimits->incrementStreamWriteScore($output); - - $buffer .= $output; - - if (strlen($buffer) >= self::MAX_BUFFERED_BYTES) { - yield $buffer; - $buffer = ''; - } - } - - if ($buffer !== '') { - yield $buffer; + yield $output; } } catch (LiquidException $e) { - if ($buffer !== '') { - yield $buffer; - } - $e->templateName = $e->templateName ?? $this->root->name; - throw $e; - } catch (\Throwable $e) { - if ($buffer !== '') { - yield $buffer; - } - throw $e; } finally { $this->state->errors = $context->getErrors();