From 4e60434a2455ff4edc80cfd45fbeba5d5a1feb36 Mon Sep 17 00:00:00 2001 From: John Koster Date: Thu, 13 Aug 2026 02:29:51 -0500 Subject: [PATCH 01/12] New include tag --- src/Providers/ExtensionServiceProvider.php | 1 + src/Tags/Concerns/RendersViews.php | 92 ++++++ src/Tags/IncludeTag.php | 285 ++++++++++++++++++ src/Tags/Partial.php | 92 +----- .../Language/Parser/DocumentParser.php | 2 +- .../Runtime/Concerns/ManagesIncludeSlots.php | 111 +++++++ .../Language/Runtime/GlobalRuntimeState.php | 15 +- .../Language/Runtime/NodeProcessor.php | 33 +- .../Language/Runtime/RuntimeParser.php | 6 +- src/View/Blade/Concerns/CompilesPartials.php | 103 +++++-- src/View/Blade/StatamicTagCompiler.php | 9 + src/View/Slot.php | 68 +++++ 12 files changed, 703 insertions(+), 114 deletions(-) create mode 100644 src/Tags/Concerns/RendersViews.php create mode 100644 src/Tags/IncludeTag.php create mode 100644 src/View/Antlers/Language/Runtime/Concerns/ManagesIncludeSlots.php create mode 100644 src/View/Slot.php diff --git a/src/Providers/ExtensionServiceProvider.php b/src/Providers/ExtensionServiceProvider.php index 5371de78bcf..b237ba4ae0d 100644 --- a/src/Providers/ExtensionServiceProvider.php +++ b/src/Providers/ExtensionServiceProvider.php @@ -185,6 +185,7 @@ class ExtensionServiceProvider extends ServiceProvider Tags\GetSite::class, Tags\Glide::class, Tags\In::class, + Tags\IncludeTag::class, Tags\Increment::class, Tags\Installed::class, Tags\Is::class, diff --git a/src/Tags/Concerns/RendersViews.php b/src/Tags/Concerns/RendersViews.php new file mode 100644 index 00000000000..c9b4a20785c --- /dev/null +++ b/src/Tags/Concerns/RendersViews.php @@ -0,0 +1,92 @@ +exists($underscored = $this->underscoredViewName($partial))) { + return $underscored; + } + + if (view()->exists($subdirectoried = 'partials.'.$partial)) { + return $subdirectoried; + } + + if (view()->exists($underscored_subdirectoried = 'partials.'.$this->underscoredViewName($partial))) { + return $underscored_subdirectoried; + } + + return $partial; + } + + protected function underscoredViewName($partial) + { + $bits = collect(explode('.', $partial)); + + $last = $bits->pull($bits->count() - 1); + + return $bits->implode('.').'._'.$last; + } + + protected function shouldRender(): bool + { + if ($this->params->has('when')) { + return $this->params->bool('when'); + } + + if ($this->params->has('unless')) { + return ! $this->params->bool('unless'); + } + + return true; + } + + protected function getSlotContent(): HtmlString|string + { + $content = trim($this->parse()); + + if ($this->isAntlersBladeComponent()) { + return new HtmlString($content); + } + + return $content; + } + + /** + * The {{ exists }} tag. + * + * Returns true if the view exists, false otherwise. If the src parameter is + * omitted, it acts like the user is trying to use a view named "exists". + */ + public function exists() + { + if (! $view = $this->params->get('src')) { + return $this->wildcard('exists'); + } + + return view()->exists($this->viewName($view)); + } + + /** + * The {{ if_exists }} tag. + * + * Renders the view if it exists, and outputs nothing otherwise. If the src parameter + * is omitted, it acts like the user is trying to use a view named "if_exists". + */ + public function ifExists() + { + if (! $view = $this->params->get('src')) { + return $this->wildcard('if_exists'); + } + + if (view()->exists($this->viewName($view))) { + return $this->render($view); + } + } +} diff --git a/src/Tags/IncludeTag.php b/src/Tags/IncludeTag.php new file mode 100644 index 00000000000..0584b8de25b --- /dev/null +++ b/src/Tags/IncludeTag.php @@ -0,0 +1,285 @@ +params->get('src', $tag); + + if (! $view) { + throw new RuntimeException('The include tag requires a view name or the [src] parameter.'); + } + + return $this->render($view); + } + + protected function render($view) + { + $parameters = $this->params->all(); + $spread = $this->unwrap($parameters['params'] ?? null); + $prefixes = $this->unwrap($parameters['handle_prefix'] ?? null); + + $this->validateReserved($parameters, $spread, $prefixes); + + if (! $this->shouldRender()) { + return ''; + } + + $data = $this->resolveData($parameters, $this->spread($spread), $prefixes); + $view = view($this->viewName($view)); + $isBlade = ! Str::endsWith($view->getPath(), Engine::EXTENSIONS); + + $cascade = Cascade::toArray(); + + $scope = array_merge( + $this->params->bool('cascade') ? $cascade : [], + $data, + $this->resolveSlots($parameters, $data, $isBlade), + [ + 'params' => $data, + '__frontmatter' => $data, + ], + $isBlade ? [self::CONTEXT_KEY => true] : [] + ); + + $hadViews = array_key_exists('views', $cascade); + $viewsState = $cascade['views'] ?? null; + + try { + return $view->with($scope) + ->withoutExtractions() + ->render(); + } finally { + if ($hadViews) { + Cascade::set('views', $viewsState); + } elseif (Cascade::get('views') !== null) { + Cascade::data(Arr::except(Cascade::toArray(), 'views')); + } + } + } + + protected function resolveData(array $parameters, array $spread, mixed $prefixes): array + { + $named = []; + + foreach ($parameters as $key => $value) { + if ($this->isDataParameter($key, $value)) { + $named[$key] = $value; + } + } + + return array_merge( + $spread, + $this->unprefixedAliases($spread, $prefixes), + $named, + $this->unprefixedAliases($named, $prefixes) + ); + } + + protected function resolveSlots(array $parameters, array $data, bool $isBlade): array + { + $slots = []; + + foreach ($parameters as $key => $value) { + if ($this->isSlotParameter($key, $value)) { + $slots[substr($key, strlen(self::SLOT_PARAM_PREFIX))] = $value; + } + } + + if ($this->isolatedContext === null && $this->isPair && ! isset($slots['slot'])) { + $content = $this->getSlotContent(); + + if ((string) $content !== '') { + $slots['slot'] = $content; + } + } + + if (empty($slots)) { + return []; + } + + $normalized = []; + $namedSlots = []; + + foreach ($slots as $name => $slot) { + if ($slot instanceof Slot) { + $slot->withParams($data); + } + + if ($name === 'slot') { + $normalized['slot'] = $slot; + + continue; + } + + $normalized['slot:'.$name] = $slot; + $namedSlots[$name] = $slot; + + if ($isBlade && $this->canAliasSlot($name, $data)) { + $normalized[$name] = $slot; + } + } + + if ($isBlade) { + $normalized[self::SLOTS_KEY] = $namedSlots; + } + + if (! empty($namedSlots)) { + $normalized[GlobalRuntimeState::createIndicatorVariable( + GlobalRuntimeState::INDICATOR_NAMED_SLOTS_AVAILABLE + )] = true; + } + + return $normalized; + } + + protected function canAliasSlot(string $name, array $data): bool + { + return ! array_key_exists($name, $data) + && ! str_starts_with($name, '__') + && ! in_array($name, self::PROTECTED_ALIASES); + } + + protected function isDataParameter(int|string $key, mixed $value): bool + { + return ! in_array($key, self::CONTROL) + && ! in_array($key, self::RESERVED) + && ! $this->isSlotParameter($key, $value); + } + + protected function isSlotParameter(int|string $key, mixed $value): bool + { + return $this->hasSlotPrefix($key) && $value instanceof Slot; + } + + protected function hasSlotPrefix(int|string $key): bool + { + return is_string($key) && str_starts_with($key, self::SLOT_PARAM_PREFIX); + } + + protected function isPrefixedKey(int|string $key, string $prefix): bool + { + return is_string($key) && str_starts_with($key, $prefix) && strlen($key) > strlen($prefix); + } + + protected function spread(mixed $spread): array + { + if ($spread === null) { + return []; + } + + if (! is_array($spread) || (! empty($spread) && ! Arr::isAssoc($spread))) { + throw new RuntimeException('The [params] parameter on the include tag must be an associative array.'); + } + + return Arr::except($spread, self::CONTROL); + } + + protected function unprefixedAliases(array $data, mixed $prefixes): array + { + $aliases = []; + + foreach (array_reverse(Arr::wrap($prefixes)) as $prefix) { + if (! is_string($prefix) || $prefix === '') { + continue; + } + + foreach ($data as $key => $value) { + if ($this->isPrefixedKey($key, $prefix)) { + $aliases[substr($key, strlen($prefix))] = $value; + } + } + } + + return $aliases; + } + + protected function validateReserved(array $parameters, mixed $spread, mixed $prefixes): void + { + $this->validateKeys($parameters, allowSlots: true); + + if (! is_array($spread)) { + return; + } + + $this->validateKeys($spread); + $this->validateKeys($this->unprefixedAliases($spread, $prefixes)); + $this->validateKeys($this->unprefixedAliases(Arr::except($parameters, self::CONTROL), $prefixes)); + } + + protected function validateKeys(array $parameters, bool $allowSlots = false): void + { + foreach ($parameters as $key => $value) { + $allowedSlot = $allowSlots && $this->isSlotParameter($key, $value); + + if (in_array($key, self::RESERVED) || ($this->hasSlotPrefix($key) && ! $allowedSlot)) { + throw new RuntimeException("Cannot pass reserved parameter [{$key}] to the include tag."); + } + } + } + + protected function unwrap(mixed $value): mixed + { + if ($value instanceof Value) { + $value = $value->value(); + } + + if ($value instanceof Arrayable) { + $value = $value->toArray(); + } + + return $value; + } +} diff --git a/src/Tags/Partial.php b/src/Tags/Partial.php index f487f31faaa..4c14d3a6dd4 100644 --- a/src/Tags/Partial.php +++ b/src/Tags/Partial.php @@ -2,10 +2,12 @@ namespace Statamic\Tags; -use Illuminate\Support\HtmlString; +use Statamic\Tags\Concerns\RendersViews; class Partial extends Tags { + use RendersViews; + public function wildcard($tag) { // We pass the original non-studly case value in as @@ -21,7 +23,9 @@ protected function render($partial) return; } - $variables = array_merge($this->context->all(), $this->params->all(), [ + $context = array_diff_key($this->context->all(), array_flip(IncludeTag::VIEW_DATA_KEYS)); + + $variables = array_merge($context, $this->params->all(), [ '__frontmatter' => $this->params->all(), 'slot' => $this->isPair ? $this->getSlotContent() : null, ]); @@ -30,88 +34,4 @@ protected function render($partial) ->withoutExtractions() ->render(); } - - private function getSlotContent() - { - $content = trim($this->parse()); - - if ($this->isAntlersBladeComponent()) { - return new HtmlString($content); - } - - return $content; - } - - protected function shouldRender(): bool - { - if ($this->params->has('when')) { - return $this->params->bool('when'); - } - - if ($this->params->has('unless')) { - return ! $this->params->bool('unless'); - } - - return true; - } - - protected function viewName($partial) - { - $partial = str_replace('/', '.', $partial); - - if (view()->exists($underscored = $this->underscoredViewName($partial))) { - return $underscored; - } - - if (view()->exists($subdirectoried = 'partials.'.$partial)) { - return $subdirectoried; - } - - if (view()->exists($underscored_subdirectoried = 'partials.'.$this->underscoredViewName($partial))) { - return $underscored_subdirectoried; - } - - return $partial; - } - - protected function underscoredViewName($partial) - { - $bits = collect(explode('.', $partial)); - - $last = $bits->pull($bits->count() - 1); - - return $bits->implode('.').'._'.$last; - } - - /** - * The {{ partial:exists }} tag. - * - * Returns true if the partial exists, false otherwise. - * If the src parameter is omitted, it acts like the user is trying to use a partial named "exists". - */ - public function exists() - { - if (! $partial = $this->params->get('src')) { - return $this->wildcard('exists'); - } - - return view()->exists($this->viewName($partial)); - } - - /** - * The {{ partial:if_exists }} tag. - * - * Returns true if the partial exists, false otherwise. - * If the src parameter is omitted, it acts like the user is trying to use a partial named "if_exists". - */ - public function ifExists() - { - if (! $partial = $this->params->get('src')) { - return $this->wildcard('if_exists'); - } - - if (view()->exists($this->viewName($partial))) { - return $this->render($partial); - } - } } diff --git a/src/View/Antlers/Language/Parser/DocumentParser.php b/src/View/Antlers/Language/Parser/DocumentParser.php index 5aff3926646..ee5f47b2adc 100644 --- a/src/View/Antlers/Language/Parser/DocumentParser.php +++ b/src/View/Antlers/Language/Parser/DocumentParser.php @@ -1577,7 +1577,7 @@ public function resetState() /** @var AntlersNode $lastTagNode */ $lastTagNode = GlobalRuntimeState::$globalTagEnterStack[count(GlobalRuntimeState::$globalTagEnterStack) - 1]; - if ($lastTagNode->name->name != 'partial') { + if (! in_array($lastTagNode->name->name, ['partial', 'include'])) { $this->setStartLineSeed($lastTagNode->endPosition->line); } } diff --git a/src/View/Antlers/Language/Runtime/Concerns/ManagesIncludeSlots.php b/src/View/Antlers/Language/Runtime/Concerns/ManagesIncludeSlots.php new file mode 100644 index 00000000000..3e91115f9d4 --- /dev/null +++ b/src/View/Antlers/Language/Runtime/Concerns/ManagesIncludeSlots.php @@ -0,0 +1,111 @@ +buildIncludeSlots($node, $tagActiveData) as $name => $slot) { + $tagParameters[IncludeTag::SLOT_PARAM_PREFIX.$name] = $slot; + } + + return $tagParameters; + } + + protected function buildIncludeSlots(AntlersNode $node, array $callerData): array + { + $namedSlots = []; + $defaultChildren = []; + + foreach ($node->children as $child) { + if ($child instanceof AntlersNode && $child->isClosingTag) { + continue; + } + + if ($this->isNamedSlotNode($child)) { + $namedSlots[$child->name->methodPart] = $child; + + continue; + } + + $defaultChildren[] = $child; + } + + $slots = []; + + if ($this->slotHasContent($defaultChildren)) { + $slots['slot'] = $this->makeSlot($defaultChildren, $callerData); + } + + foreach ($namedSlots as $slotName => $slotNode) { + if ($this->slotHasContent($slotNode->children)) { + $slots[$slotName] = $this->makeSlot($slotNode->children, $callerData); + } + } + + return $slots; + } + + protected function makeSlot(array $nodes, array $callerData): Slot + { + $callerState = [GlobalRuntimeState::$isCascadeEnabled, GlobalRuntimeState::$prefixState]; + + $renderer = function (array $data) use ($nodes, $callerState) { + $tagState = [GlobalRuntimeState::$isCascadeEnabled, GlobalRuntimeState::$prefixState]; + + [GlobalRuntimeState::$isCascadeEnabled, GlobalRuntimeState::$prefixState] = $callerState; + + try { + return $this->cloneProcessor()->setData($data)->reduce($nodes); + } finally { + [GlobalRuntimeState::$isCascadeEnabled, GlobalRuntimeState::$prefixState] = $tagState; + } + }; + + return new Slot($renderer, $callerData); + } + + protected function isNamedSlotNode($node): bool + { + return $node instanceof AntlersNode && ! $node->isComment && + $node->name != null && $node->name->name == 'slot' && + $node->name->methodPart != null; + } + + protected function slotHasContent(array $children): bool + { + foreach ($children as $child) { + if ($child instanceof LiteralNode) { + if (trim($child->content) !== '') { + return true; + } + + continue; + } + + if ($child instanceof AntlersNode && ($child->isComment || $child->isClosingTag)) { + continue; + } + + return true; + } + + return false; + } + + protected function getSlotOutputProps(AntlersNode $node): array + { + $lockData = $this->data; + $props = $node->getParameterValues($this, $this->getActiveData()); + $this->data = $lockData; + + return $props; + } +} diff --git a/src/View/Antlers/Language/Runtime/GlobalRuntimeState.php b/src/View/Antlers/Language/Runtime/GlobalRuntimeState.php index f0a3ddee5f4..012abd2259f 100644 --- a/src/View/Antlers/Language/Runtime/GlobalRuntimeState.php +++ b/src/View/Antlers/Language/Runtime/GlobalRuntimeState.php @@ -251,6 +251,7 @@ public static function captureRuntimeState(): array self::$requiresRuntimeIsolation, self::$traceTagAssignments, self::$tracedRuntimeAssignments, + self::$isCascadeEnabled, ]; } @@ -265,16 +266,18 @@ public static function captureAndIsolate(): array public static function restoreState(array $capturedState): void { - [$requiresIsolation, $traceTagAssignments, $tracedRuntimeAssignments] = $capturedState; - - self::$requiresRuntimeIsolation = $requiresIsolation; - self::$traceTagAssignments = $traceTagAssignments; - self::$tracedRuntimeAssignments = $tracedRuntimeAssignments; - self::$isCascadeEnabled = true; + self::$requiresRuntimeIsolation = $capturedState[0]; + self::$traceTagAssignments = $capturedState[1]; + self::$tracedRuntimeAssignments = $capturedState[2]; + // Forcing true when absent is technically incorrect: the caller may itself be + // isolated, and this re-enables its cascade access mid-render. Preserved + // for backwards compatibility and not causing too much chaos and pain + self::$isCascadeEnabled = $capturedState[3] ?? true; } public static function resetGlobalState() { + self::$isCascadeEnabled = true; self::$templateFileStack = []; self::$shareVariablesTemplateTrigger = ''; self::$layoutVariables = []; diff --git a/src/View/Antlers/Language/Runtime/NodeProcessor.php b/src/View/Antlers/Language/Runtime/NodeProcessor.php index 1d7bf42b81a..4994c3e5ded 100644 --- a/src/View/Antlers/Language/Runtime/NodeProcessor.php +++ b/src/View/Antlers/Language/Runtime/NodeProcessor.php @@ -47,6 +47,7 @@ use Statamic\View\Antlers\Language\Nodes\Structures\SwitchGroup; use Statamic\View\Antlers\Language\Nodes\VariableNode; use Statamic\View\Antlers\Language\Parser\LanguageParser; +use Statamic\View\Antlers\Language\Runtime\Concerns\ManagesIncludeSlots; use Statamic\View\Antlers\Language\Runtime\Debugging\GlobalDebugManager; use Statamic\View\Antlers\Language\Runtime\Sandbox\Environment; use Statamic\View\Antlers\Language\Runtime\Sandbox\RuntimeValues; @@ -54,11 +55,14 @@ use Statamic\View\Antlers\Language\Utilities\StringUtilities; use Statamic\View\Antlers\SyntaxError; use Statamic\View\Cascade; +use Statamic\View\Slot; use Statamic\View\State\CachesOutput; use Throwable; class NodeProcessor { + use ManagesIncludeSlots; + /** * @var Loader */ @@ -1582,6 +1586,10 @@ public function reduce($processNodes) $this->data = $lockData; } + if ($node->name->name == 'include') { + $tagParameters = $this->captureIncludeSlots($node, $tagActiveData, $tagParameters); + } + if ($node->name->name == 'partial' || $node->name->name == 'scope') { if (array_key_exists('handle_prefix', $tagParameters)) { $handlePrefixes = $tagParameters['handle_prefix']; @@ -1624,6 +1632,7 @@ public function reduce($processNodes) $suspendedData = null; $capturedRuntimeState = null; + $suspendedPrefixes = null; if ($tag::$isolated) { $tag->setIsolatedContext($tagActiveData); @@ -1632,6 +1641,13 @@ public function reduce($processNodes) $capturedRuntimeState = GlobalRuntimeState::captureAndIsolate(); } + // Other isolated tags inheriting handle prefixes is technically unintentional, + // but preserved for BC. This may change in the next major version. + if ($node->name->name == 'include') { + $suspendedPrefixes = GlobalRuntimeState::$prefixState; + GlobalRuntimeState::$prefixState = []; + } + if (in_array(CachesOutput::class, class_implements($tag))) { $isCacheTag = true; GlobalRuntimeState::$isCacheEnabled = true; @@ -1667,11 +1683,15 @@ public function reduce($processNodes) GlobalRuntimeState::$evaulatingTagContents = false; $this->stopMeasuringTag(); - if ($suspendedData != null) { + if ($capturedRuntimeState !== null) { $this->data = $suspendedData; GlobalRuntimeState::restoreState($capturedRuntimeState); } + + if ($suspendedPrefixes !== null) { + GlobalRuntimeState::$prefixState = $suspendedPrefixes; + } } $afterAssignments = $this->runtimeAssignments; @@ -2153,6 +2173,17 @@ public function reduce($processNodes) $val = $val->get()->all(); } + if ($val instanceof Slot) { + $val = $val->render($node->hasParameters ? $this->getSlotOutputProps($node) : []); + $buffer .= $this->measureBufferAppend($node, $this->modifyBufferAppend($val)); + + if ($this->isTracingEnabled()) { + $this->runtimeConfiguration->traceManager->traceOnExit($node, null); + } + + continue; + } + $executedParamModifiers = false; if ($tagCallbackResult != null) { diff --git a/src/View/Antlers/Language/Runtime/RuntimeParser.php b/src/View/Antlers/Language/Runtime/RuntimeParser.php index e8883b58a49..39b9f9546e8 100644 --- a/src/View/Antlers/Language/Runtime/RuntimeParser.php +++ b/src/View/Antlers/Language/Runtime/RuntimeParser.php @@ -379,7 +379,7 @@ protected function renderText($text, $data = []) /** @var AntlersNode $lastTagNode */ $lastTagNode = GlobalRuntimeState::$globalTagEnterStack[count(GlobalRuntimeState::$globalTagEnterStack) - 1]; - if ($lastTagNode->name->name != 'partial') { + if (! in_array($lastTagNode->name->name, ['partial', 'include'])) { $this->documentParser->setStartLineSeed($lastTagNode->endPosition->line); } } @@ -770,9 +770,13 @@ public function parseView($view, $text, $data = []) GlobalRuntimeState::$isEvaluatingUserData = false; $existingView = $this->view; + + $suspendedData = $this->nodeProcessor->getAllData(); + try { return $this->renderViewContent($view, $text, $data); } finally { + $this->nodeProcessor->swapData($suspendedData); $this->view = $existingView; array_pop(GlobalRuntimeState::$templateFileStack); GlobalRuntimeState::$currentExecutionFile = $this->view; diff --git a/src/View/Blade/Concerns/CompilesPartials.php b/src/View/Blade/Concerns/CompilesPartials.php index c265501b50c..99693bd653e 100644 --- a/src/View/Blade/Concerns/CompilesPartials.php +++ b/src/View/Blade/Concerns/CompilesPartials.php @@ -3,6 +3,8 @@ namespace Statamic\View\Blade\Concerns; use Illuminate\Support\Str; +use InvalidArgumentException; +use Statamic\Tags\IncludeTag; use Stillat\BladeParser\Nodes\Components\ComponentNode; use Stillat\BladeParser\Nodes\Components\ParameterNode; use Stillat\BladeParser\Nodes\Components\ParameterType; @@ -17,6 +19,22 @@ protected function isSlotTag(string $tagName): bool return $tagName === 'slot' || str($tagName)->startsWith(['slot.', 'slot:']); } + private function compileSlotOutput(ComponentNode $component): string + { + if (! $this->isValidSlotName($name = $this->rawSlotName($component))) { + return $this->compileComponent($component); + } + + $slot = $name === 'slot' + ? '($slot ?? null)' + : '($'.IncludeTag::SLOTS_KEY.'['.var_export($name, true).'] ?? null)'; + + $context = '$'.IncludeTag::CONTEXT_KEY.' ?? false'; + $output = '\Statamic\View\Slot::output('.$slot.', '.$this->compileParameters($component->parameters).')'; + + return ''.$this->compileComponent($component).''; + } + protected function isComponentSlot(ComponentNode $parent, ComponentNode $child): bool { return $child->parent === $parent && $this->isSlotTag($child->tagName); @@ -52,47 +70,96 @@ protected function compileSlot(ComponentNode $node): array return [$name, $compiled]; } + private function compileIncludeSlot(ComponentNode $node): array + { + $name = $this->rawSlotName($node); + + if (! $this->isValidSlotName($name)) { + throw new InvalidArgumentException("Invalid slot name [{$name}]."); + } + + return [$name, $this->compile($node->innerDocumentContent)]; + } + + private function rawSlotName(ComponentNode $component): string + { + $name = (string) str($component->name)->substr(5); + + return $name === '' ? 'slot' : $name; + } + + private function isValidSlotName(string $name): bool + { + return (bool) preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $name); + } + protected function compilePartial(ComponentNode $component): string + { + return $this->compileViewTag($component, isInclude: false); + } + + private function compileInclude(ComponentNode $component): string + { + return $this->compileViewTag($component, isInclude: true); + } + + private function compileViewTag(ComponentNode $component, bool $isInclude): string { [$slots, $newContent] = $this->extractSlots($component); $params = $component->getParameters()->keyBy(fn (ParameterNode $param) => $param->materializedName); $forwardMethods = ['exists', 'if_exists']; - if (str($component->tagName)->startsWith('partial:')) { - $partialName = (string) str($component->tagName)->substr(8); + [$baseName, $method, $originalMethod] = $this->extractMethodNames($component); + $baseName = Str::lower($baseName); - if (! in_array($partialName, $forwardMethods)) { - $srcParam = new ParameterNode(); - $srcParam->type = ParameterType::Parameter; - $srcParam->setName('src'); - $srcParam->setValue($partialName); - $params['src'] = $srcParam; - } + if (str_contains($component->tagName, ':') && ! in_array($originalMethod, $forwardMethods)) { + $srcParam = new ParameterNode(); + $srcParam->type = ParameterType::Parameter; + $srcParam->setName('src'); + $srcParam->setValue($originalMethod); + $params['src'] = $srcParam; } $hoistedSet = ''; $hoistedUnset = ''; + $compiledSlots = array_map( + fn ($slot) => $isInclude ? $this->compileIncludeSlot($slot) : $this->compileSlot($slot), + $slots + ); + + if ($isInclude && Str::snake($method) !== 'exists') { + if (trim($newContent) !== '') { + $compiledSlots[] = ['slot', $this->compile($newContent)]; + } + + $newContent = ''; + } + // The label is randomized so slot content containing the terminator cannot break out of the nowdoc. $set = <<<'SET' -$$varName = <<<'COMPILED' +$$varName = <<<'$label' #compiled# -COMPILED; +$label; SET; $unset = <<<'UNSET' unset($$varName); UNSET; - foreach ($slots as $slot) { + foreach ($compiledSlots as [$name, $compiled]) { $hoistedVarName = '__partialSlot'.Str::random(32); - [$name, $compiled] = $this->compileSlot($slot); + $hoistedLabel = 'COMPILED'.Str::random(32); $injectedParam = new ParameterNode(); - $injectedParam->setName($name); + $paramName = $isInclude ? IncludeTag::SLOT_PARAM_PREFIX.$name : $name; + $injectedParam->setName($paramName); $injectedParam->type = ParameterType::DynamicVariable; - $injectedParam->value = 'new \Illuminate\Support\HtmlString(\Illuminate\Support\Facades\Blade::render($'.$hoistedVarName.', get_defined_vars()))'; + $injectedParam->value = $isInclude + ? 'new \Statamic\View\Slot(fn ($__slotData) => \Illuminate\Support\Facades\Blade::render($'.$hoistedVarName.', $__slotData), get_defined_vars())' + : 'new \Illuminate\Support\HtmlString(\Illuminate\Support\Facades\Blade::render($'.$hoistedVarName.', get_defined_vars()))'; $hoistedSet .= Str::swap([ '$varName' => $hoistedVarName, + '$label' => $hoistedLabel, '#compiled#' => $compiled, ], $set); @@ -100,7 +167,7 @@ protected function compilePartial(ComponentNode $component): string '$varName' => $hoistedVarName, ], $unset); - $params[$name] = $injectedParam; + $params[$paramName] = $injectedParam; } $compiledNode = <<<'PHP' @@ -134,8 +201,6 @@ protected function compilePartial(ComponentNode $component): string ?> PHP; - [$name, $method, $originalMethod] = $this->extractMethodNames($component); - if (! in_array(Str::snake($method), $forwardMethods)) { $method = $originalMethod = 'index'; } @@ -149,7 +214,7 @@ protected function compilePartial(ComponentNode $component): string '#set#' => $hoistedSet, '#unset#' => $hoistedUnset, '$tagMethod' => "'".$method."'", - '$tagName' => 'partial', + '$tagName' => $baseName, '$originalMethod' => "'".$originalMethod."'", ] ); diff --git a/src/View/Blade/StatamicTagCompiler.php b/src/View/Blade/StatamicTagCompiler.php index 45d7dfc0ad9..651cfabaa19 100644 --- a/src/View/Blade/StatamicTagCompiler.php +++ b/src/View/Blade/StatamicTagCompiler.php @@ -103,6 +103,10 @@ public function compile(string $template): string return $this->compileNocache($node); } elseif ($this->isPartial($node)) { return $this->compilePartial($node); + } elseif ($this->isInclude($node)) { + return $this->compileInclude($node); + } elseif ($this->isSlotTag($node->tagName)) { + return $this->compileSlotOutput($node); } elseif ($this->interceptNav && $this->isStructure($node->tagName)) { return $this->compileNav($node); } @@ -123,6 +127,11 @@ protected function isPartial(ComponentNode $component): bool return $component->tagName == 'partial' || str($component->tagName)->lower()->startsWith('partial:'); } + private function isInclude(ComponentNode $component): bool + { + return $component->tagName == 'include' || str($component->tagName)->lower()->startsWith('include:'); + } + protected function extractMethodNames(ComponentNode $component): array { $name = $component->tagName; diff --git a/src/View/Slot.php b/src/View/Slot.php new file mode 100644 index 00000000000..6f0a7e8ac1c --- /dev/null +++ b/src/View/Slot.php @@ -0,0 +1,68 @@ +data, ['params' => $this->params], $props); + + return trim((string) ($this->renderer)($data)); + } + + public function withParams(array $params): static + { + $this->params = $params; + + return $this; + } + + public function toHtml(): string + { + return $this->render(); + } + + public function __serialize(): array + { + return ['rendered' => $this->render()]; + } + + public function __unserialize(array $data): void + { + $rendered = $data['rendered'] ?? ''; + + $this->renderer = fn () => $rendered; + $this->data = []; + $this->params = []; + } + + public function __toString(): string + { + return $this->render(); + } + + public static function output(mixed $slot, array $props = []): string + { + if ($slot instanceof self) { + return $slot->render($props); + } + + return e($slot); + } +} From 8e1cf0dd9242763decf2f0c6354aea9158f7d7e7 Mon Sep 17 00:00:00 2001 From: John Koster Date: Thu, 13 Aug 2026 12:21:20 -0500 Subject: [PATCH 02/12] Tests --- .../Components/ComponentsCascadeTest.php | 16 + .../Antlers/Runtime/Includes/CascadeTest.php | 264 +++++++++++++ .../Runtime/Includes/IncludeTagTest.php | 211 +++++++++++ .../Antlers/Runtime/Includes/InteropTest.php | 195 ++++++++++ tests/Antlers/Runtime/Includes/IssuesTest.php | 70 ++++ tests/Antlers/Runtime/Includes/NestedTest.php | 146 +++++++ .../Antlers/Runtime/Includes/SandboxTest.php | 147 +++++++ tests/Antlers/Runtime/Includes/SlotsTest.php | 253 +++++++++++++ .../AntlersComponents/IncludeCompilerTest.php | 358 ++++++++++++++++++ 9 files changed, 1660 insertions(+) create mode 100644 tests/Antlers/Runtime/Includes/CascadeTest.php create mode 100644 tests/Antlers/Runtime/Includes/IncludeTagTest.php create mode 100644 tests/Antlers/Runtime/Includes/InteropTest.php create mode 100644 tests/Antlers/Runtime/Includes/IssuesTest.php create mode 100644 tests/Antlers/Runtime/Includes/NestedTest.php create mode 100644 tests/Antlers/Runtime/Includes/SandboxTest.php create mode 100644 tests/Antlers/Runtime/Includes/SlotsTest.php create mode 100644 tests/View/Blade/AntlersComponents/IncludeCompilerTest.php diff --git a/tests/Antlers/Components/ComponentsCascadeTest.php b/tests/Antlers/Components/ComponentsCascadeTest.php index 2ce39e9fe0c..3118f9d9288 100644 --- a/tests/Antlers/Components/ComponentsCascadeTest.php +++ b/tests/Antlers/Components/ComponentsCascadeTest.php @@ -20,6 +20,22 @@ protected function createEntry() EntryFactory::collection('blog')->id('1')->slug('one')->data(['title' => 'One'])->create(); } + public function test_a_component_does_not_re_enable_the_cascade_for_an_isolated_caller() + { + $this->createEntry(); + + $this->withFakeViews(); + $this->viewShouldReturnRaw('layout', '{{ template_content }}'); + $this->viewShouldReturnRaw('default', '{{ include:shell }}'); + $this->viewShouldReturnRaw('shell', '[{{ title }}][{{ title }}]'); + $this->viewShouldReturnRaw('components.scope.cascade', 'C'); + + $this->assertSame( + '[]C[]', + Str::squish($this->get('one')->assertOk()->getContent()) + ); + } + public function test_cascade_does_not_leak_into_components() { $this->createEntry(); diff --git a/tests/Antlers/Runtime/Includes/CascadeTest.php b/tests/Antlers/Runtime/Includes/CascadeTest.php new file mode 100644 index 00000000000..0e545376496 --- /dev/null +++ b/tests/Antlers/Runtime/Includes/CascadeTest.php @@ -0,0 +1,264 @@ +withFakeViews(); + + Cascade::set('cval', 'C'); + } + + private function render($template, $data = []) + { + return $this->renderString($template, $data, true); + } + + public function test_a_view_only_reaches_the_cascade_when_it_asks_to() + { + $this->viewShouldReturnRaw('x', 'X[{{ cval }}]'); + + $this->assertSame('X[]', $this->render('{{ include:x }}')); + $this->assertSame('X[C]', $this->render('{{ include:x cascade="true" }}')); + } + + public function test_the_caller_keeps_the_cascade_on_both_sides_of_an_include() + { + $this->viewShouldReturnRaw('x', 'X'); + + $this->assertSame('[C]X[C]', $this->render('[{{ cval }}]{{ include:x }}[{{ cval }}]')); + } + + public function test_the_caller_keeps_the_cascade_after_an_include_in_a_loop() + { + $this->viewShouldReturnRaw('r', 'R'); + + $this->assertSame('RR[C]', $this->render('{{ items }}{{ include:r }}{{ /items }}[{{ cval }}]', ['items' => [[], []]])); + } + + public function test_a_nested_include_does_not_inherit_the_cascade() + { + $this->viewShouldReturnRaw('l1', 'L1[{{ cval }}]{{ include:l2 }}'); + $this->viewShouldReturnRaw('l2', 'L2[{{ cval }}]'); + + $this->assertSame('L1[C]L2[]', $this->render('{{ include:l1 cascade="true" }}')); + } + + public function test_a_nested_include_does_not_enable_the_cascade_for_its_parent() + { + $this->viewShouldReturnRaw('outer', '[{{ cval }}]{{ include:inner }}[{{ cval }}]'); + $this->viewShouldReturnRaw('inner', '[{{ cval }}]'); + + $this->assertSame('[][][]', $this->render('{{ include:outer }}')); + } + + public function test_a_nested_include_can_still_opt_in() + { + $this->viewShouldReturnRaw('l1', 'L1[{{ cval }}]{{ include:l2 cascade="true" }}'); + $this->viewShouldReturnRaw('l2', 'L2[{{ cval }}]'); + + $this->assertSame('L1[]L2[C]', $this->render('{{ include:l1 }}')); + } + + public function test_slot_contents_resolve_cascade_values_like_the_caller_does() + { + $this->viewShouldReturnRaw('default', '{{ slot }}'); + $this->viewShouldReturnRaw('named', '{{ slot:h }}'); + $this->viewShouldReturnRaw('scoped', '{{ slot:h :n="1" }}'); + + $this->assertSame('[C]', $this->render('{{ include:default }}[{{ cval }}]{{ /include:default }}')); + $this->assertSame('[C]', $this->render('{{ include:named }}{{ slot:h }}[{{ cval }}]{{ /slot:h }}{{ /include:named }}')); + $this->assertSame('[C|1]', $this->render('{{ include:scoped }}{{ slot:h }}[{{ cval }}|{{ n }}]{{ /slot:h }}{{ /include:scoped }}')); + } + + public function test_slot_contents_written_inside_a_view_use_that_views_cascade_state() + { + $this->viewShouldReturnRaw('outer', '{{ include:wrapper }}[{{ cval }}]{{ /include:wrapper }}'); + $this->viewShouldReturnRaw('wrapper', '{{ slot }}'); + + $this->assertSame('[]', $this->render('{{ include:outer }}')); + $this->assertSame('[C]', $this->render('{{ include:outer cascade="true" }}')); + } + + public function test_a_partial_rendered_inside_an_include_follows_the_includes_cascade_state() + { + $this->viewShouldReturnRaw('x', 'X[{{ cval }}]{{ partial:p }}'); + $this->viewShouldReturnRaw('p', 'P[{{ cval }}]'); + + $this->assertSame('X[]P[]|[C]', $this->render('{{ include:x }}|[{{ cval }}]')); + $this->assertSame('X[C]P[C]|[C]', $this->render('{{ include:x cascade="true" }}|[{{ cval }}]')); + } + + public function test_an_include_inside_a_partial_leaves_the_partials_cascade_alone() + { + $this->viewShouldReturnRaw('p', 'P[{{ cval }}]{{ include:x }}P[{{ cval }}]'); + $this->viewShouldReturnRaw('x', 'X'); + + $this->assertSame('P[C]XP[C][C]', $this->render('{{ partial:p }}[{{ cval }}]')); + } + + public function test_a_blade_include_leaves_the_surrounding_antlers_cascade_alone() + { + $this->viewShouldReturnRaw('b', 'B[{{ $cval ?? "" }}]', 'blade.php'); + + $this->assertSame('[C]B[][C]', $this->render('[{{ cval }}]{{ include:b }}[{{ cval }}]')); + $this->assertSame('[C]B[C][C]', $this->render('[{{ cval }}]{{ include:b cascade="true" }}[{{ cval }}]')); + } + + public function test_blade_includes_reach_the_cascade_when_they_ask_to() + { + $this->viewShouldReturnRaw('b', 'B[{{ $cval ?? "" }}]', 'blade.php'); + + $this->assertSame('B[]', Blade::render('')); + $this->assertSame('B[C]', Blade::render('')); + } + + public function test_runtime_state_survives_an_exception_thrown_inside_an_include() + { + (new class extends Tags + { + protected static $handle = 'explode'; + + public function index() + { + throw new RuntimeException('boom'); + } + })::register(); + + $this->viewShouldReturnRaw('boom', '{{ explode }}'); + $this->viewShouldReturnRaw('ok', 'OK'); + + try { + $this->render('{{ include:boom }}'); + $this->fail('The exception should not have been swallowed.'); + } catch (RuntimeException $e) { + $this->assertSame('boom', $e->getMessage()); + } + + $this->assertTrue(GlobalRuntimeState::$isCascadeEnabled); + $this->assertFalse(GlobalRuntimeState::$requiresRuntimeIsolation); + $this->assertNull(Cascade::get('views')); + $this->assertSame('OK[C]', $this->render('{{ include:ok }}[{{ cval }}]')); + } + + public function test_runtime_state_survives_an_exception_thrown_inside_a_deferred_slot_render() + { + (new class extends Tags + { + protected static $handle = 'slot_boom'; + + public function index() + { + throw new RuntimeException('boom'); + } + })::register(); + + $this->viewShouldReturnRaw('w', '{{ slot }}'); + $this->viewShouldReturnRaw('ok', 'OK'); + + try { + $this->render('{{ include:w }}{{ slot_boom }}{{ /include:w }}'); + $this->fail('The exception should not have been swallowed.'); + } catch (RuntimeException $e) { + $this->assertSame('boom', $e->getMessage()); + } + + $this->assertTrue(GlobalRuntimeState::$isCascadeEnabled); + $this->assertSame([], GlobalRuntimeState::$prefixState); + $this->assertFalse(GlobalRuntimeState::$requiresRuntimeIsolation); + $this->assertSame('OK[C]', $this->render('{{ include:ok }}[{{ cval }}]')); + } + + public function test_any_isolated_tag_restores_the_previous_cascade_state() + { + $tag = new class extends Tags + { + protected static $handle = 'some_isolated_tag'; + + public static $isolated = true; + + public function index() + { + return ''; + } + }; + $tag::register(); + + $probe = new class extends Tags + { + protected static $handle = 'cascade_probe'; + + public static $seen = null; + + public function index() + { + self::$seen = GlobalRuntimeState::$isCascadeEnabled; + + return ''; + } + }; + $probe::register(); + + $this->viewShouldReturnRaw('x', '{{ some_isolated_tag }}{{ cascade_probe }}'); + + $this->render('{{ include:x }}'); + + $this->assertFalse( + $probe::$seen, + 'An isolated tag must not hand cascade access back to a caller that had isolated itself.' + ); + } + + public function test_an_include_restores_the_previous_handle_prefixes() + { + $tag = new class extends Tags + { + protected static $handle = 'prefix_probe'; + + public static $seen = null; + + public function index() + { + self::$seen = GlobalRuntimeState::$prefixState; + + return ''; + } + }; + $tag::register(); + + $this->viewShouldReturnRaw('probe', '{{ prefix_probe }}'); + + GlobalRuntimeState::$prefixState = ['hero_']; + + try { + $this->render('{{ include:probe }}'); + + $this->assertSame([], $tag::$seen, 'An include should not inherit the caller\'s handle prefixes.'); + $this->assertSame(['hero_'], GlobalRuntimeState::$prefixState); + } finally { + GlobalRuntimeState::$prefixState = []; + } + } + + public function test_resetting_global_state_restores_cascade_access() + { + GlobalRuntimeState::$isCascadeEnabled = false; + + GlobalRuntimeState::resetGlobalState(); + + $this->assertTrue(GlobalRuntimeState::$isCascadeEnabled); + } +} diff --git a/tests/Antlers/Runtime/Includes/IncludeTagTest.php b/tests/Antlers/Runtime/Includes/IncludeTagTest.php new file mode 100644 index 00000000000..bbe3e2fde12 --- /dev/null +++ b/tests/Antlers/Runtime/Includes/IncludeTagTest.php @@ -0,0 +1,211 @@ +withFakeViews(); + } + + private function render($template, $data = []) + { + return $this->renderString($template, $data, true); + } + + public function test_it_renders_a_view() + { + $this->viewShouldReturnRaw('greeting', 'Hello'); + + $this->assertSame('Hello', $this->render('{{ include:greeting }}')); + } + + public function test_it_renders_a_view_using_the_src_form() + { + $this->viewShouldReturnRaw('greeting', 'Hi'); + + $this->assertSame('Hi', $this->render('{{ include src="greeting" }}')); + } + + public function test_an_empty_src_is_rejected() + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('The include tag requires a view name or the [src] parameter.'); + + $this->render('{{ include src="" }}'); + } + + public function test_a_view_named_index_can_still_be_included() + { + $this->viewShouldReturnRaw('index', 'IDX'); + + $this->assertSame('IDX', $this->render('{{ include:index }}')); + } + + public function test_params_are_available_as_variables() + { + $this->viewShouldReturnRaw('greeting', 'Hello {{ name }}'); + + $this->assertSame('Hello World', $this->render('{{ include:greeting name="World" }}')); + } + + public function test_caller_scope_is_not_captured() + { + $this->viewShouldReturnRaw('greeting', '[{{ secret }}]'); + + $this->assertSame('[]', $this->render('{{ include:greeting }}', ['secret' => 'leak'])); + } + + public function test_loop_variables_are_not_captured() + { + $this->viewShouldReturnRaw('item', '[{{ value }}]'); + + $template = '{{ items }}{{ include:item }}{{ /items }}'; + + $this->assertSame('[][]', $this->render($template, ['items' => [['value' => 'a'], ['value' => 'b']]])); + } + + public function test_assignments_inside_an_include_do_not_leak_out() + { + $this->viewShouldReturnRaw('assigner', '{{ leaked = "in-include" }}{{ leaked }}'); + + $template = '{{ include:assigner }}|{{ leaked }}'; + + $this->assertSame('in-include|', $this->render($template)); + } + + public function test_reassigning_a_passed_variable_does_not_change_the_caller() + { + $this->viewShouldReturnRaw('reassign', '{{ foo = "changed" }}{{ foo }}'); + + $template = '{{ foo = "original" }}{{ foo }}|{{ include:reassign :foo="foo" }}|{{ foo }}'; + + $this->assertSame('original|changed|original', $this->render($template)); + } + + public function test_params_array_is_spread_into_the_scope_and_overridden_by_explicit_params() + { + $this->viewShouldReturnRaw('card', '<{{ title }}><{{ subtitle }}>'); + + $data = ['title' => 'T', 'subtitle' => 'S']; + + $this->assertSame('', $this->render('{{ include:card :params="data" }}', ['data' => $data])); + $this->assertSame('', $this->render('{{ include:card :params="data" title="Override" }}', ['data' => $data])); + } + + public function test_params_accessor_returns_the_merged_params() + { + $this->viewShouldReturnRaw('card', '[{{ params:title }}][{{ params:subtitle }}]'); + + $this->assertSame( + '[Named][S]', + $this->render('{{ include:card :params="data" title="Named" }}', ['data' => ['title' => 'T', 'subtitle' => 'S']]) + ); + } + + public function test_meta_params_never_appear_as_data() + { + $this->viewShouldReturnRaw('card', '[{{ params:src }}][{{ params:when }}][{{ params:handle_prefix }}][{{ params:params }}]'); + + $this->assertSame( + '[][][][]', + $this->render('{{ include:card :params="data" handle_prefix="x_" }}', ['data' => ['src' => 'sneaky', 'when' => 'sneaky']]) + ); + } + + public function test_params_must_be_an_associative_array() + { + $this->viewShouldReturnRaw('card', 'C'); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('must be an associative array'); + + $this->render('{{ include:card :params="bad" }}', ['bad' => ['a', 'b', 'c']]); + } + + public function test_reserved_params_cannot_be_spread() + { + $this->viewShouldReturnRaw('card', 'Card'); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Cannot pass reserved parameter [__frontmatter]'); + + $this->render('{{ include:card :params="data" }}', [ + 'data' => ['__frontmatter' => 'value'], + ]); + } + + public function test_handle_prefix_exposes_spread_values_under_both_names() + { + $this->viewShouldReturnRaw('hero', '<{{ title }}><{{ body }}>[{{ hero_title }}][{{ params:hero_title }}][{{ other }}]'); + + $template = '{{ include:hero :params="data" handle_prefix="hero_" }}'; + + $this->assertSame( + '[HT][HT][O]', + $this->render($template, ['data' => ['hero_title' => 'HT', 'hero_body' => 'HB', 'other' => 'O']]) + ); + } + + public function test_a_prefixed_spread_value_wins_over_a_non_prefixed_one() + { + $this->viewShouldReturnRaw('hero', '<{{ title }}>'); + + $template = '{{ include:hero :params="data" handle_prefix="hero_" }}'; + + $this->assertSame( + '', + $this->render($template, ['data' => ['hero_title' => 'PREFIXED', 'title' => 'PLAIN']]) + ); + } + + public function test_handle_prefix_also_applies_to_parameters_set_on_the_tag() + { + $this->viewShouldReturnRaw('hero', '<{{ title }}><{{ hero_title }}>'); + + $this->assertSame('', $this->render('{{ include:hero handle_prefix="hero_" hero_title="HT" }}')); + } + + public function test_a_parameter_set_on_the_tag_wins_under_both_names() + { + $this->viewShouldReturnRaw('hero', '<{{ title }}><{{ hero_title }}>'); + + $data = ['d' => ['hero_title' => 'FROM-SPREAD']]; + + $this->assertSame('', $this->render('{{ include:hero :params="d" handle_prefix="hero_" hero_title="OVERRIDE" }}', $data)); + $this->assertSame('', $this->render('{{ include:hero :params="d" handle_prefix="hero_" title="OVERRIDE" }}', $data)); + } + + public function test_handle_prefix_leaves_keys_it_would_reduce_to_nothing_alone() + { + $this->viewShouldReturnRaw('hero', '<{{ title }}>[{{ hero_ }}]'); + + $this->assertSame( + '[X]', + $this->render('{{ include:hero :params="d" handle_prefix="hero_" }}', ['d' => ['hero_' => 'X', 'hero_title' => 'T']]) + ); + } + + public function test_when_param_controls_rendering() + { + $this->viewShouldReturnRaw('greeting', 'Hello'); + + $this->assertSame('', $this->render('{{ include:greeting when="false" }}')); + $this->assertSame('Hello', $this->render('{{ include:greeting when="true" }}')); + } + + public function test_unless_param_controls_rendering() + { + $this->viewShouldReturnRaw('greeting', 'Hello'); + + $this->assertSame('', $this->render('{{ include:greeting unless="true" }}')); + $this->assertSame('Hello', $this->render('{{ include:greeting unless="false" }}')); + } +} diff --git a/tests/Antlers/Runtime/Includes/InteropTest.php b/tests/Antlers/Runtime/Includes/InteropTest.php new file mode 100644 index 00000000000..31ca9b91bd9 --- /dev/null +++ b/tests/Antlers/Runtime/Includes/InteropTest.php @@ -0,0 +1,195 @@ +withFakeViews(); + } + + private function render($template, $data = []) + { + return $this->renderString($template, $data, true); + } + + private function tree(): array + { + return [ + ['title' => 'A', 'children' => [ + ['title' => 'A1', 'children' => []], + ['title' => 'A2', 'children' => [['title' => 'A2a', 'children' => []]]], + ]], + ['title' => 'B', 'children' => []], + ]; + } + + public function test_a_slot_can_be_forwarded_into_a_nested_include() + { + $this->viewShouldReturnRaw('outer', 'O{{ include:inner }}{{ slot }}{{ /include:inner }}'); + $this->viewShouldReturnRaw('as_param', 'O{{ include:inner :slot="slot" }}'); + $this->viewShouldReturnRaw('inner', 'I<{{ slot }}>'); + + $this->assertSame('OI', $this->render('{{ include:outer }}BODY{{ /include:outer }}')); + $this->assertSame('OI', $this->render('{{ include:as_param }}BODY{{ /include:as_param }}')); + } + + public function test_a_slot_can_be_forwarded_through_several_levels() + { + $this->viewShouldReturnRaw('l1', '1{{ include:l2 }}<{{ slot }}>{{ /include:l2 }}'); + $this->viewShouldReturnRaw('l2', '2{{ include:l3 }}[{{ slot }}]{{ /include:l3 }}'); + $this->viewShouldReturnRaw('l3', '3({{ slot }})'); + + $this->assertSame('123([])', $this->render('{{ include:l1 }}TOP{{ /include:l1 }}')); + } + + public function test_a_slot_may_be_passed_to_another_include_under_a_different_name() + { + $this->viewShouldReturnRaw('outer', 'O{{ include:target :x="slot" }}'); + $this->viewShouldReturnRaw('target', 'T<{{ x }}>[{{ params:x }}]'); + + $this->assertSame('OT[BODY]', $this->render('{{ include:outer }}BODY{{ /include:outer }}')); + } + + public function test_stacks_can_be_pushed_to_from_a_view_and_from_slot_contents() + { + $this->viewShouldReturnRaw('pusher', '{{ push:s }}A{{ /push:s }}X'); + $this->viewShouldReturnRaw('prepender', '{{ prepend:s }}B{{ /prepend:s }}Y'); + $this->viewShouldReturnRaw('wrapper', 'W{{ slot }}'); + + $this->assertSame('BA|XY', $this->render('{{ stack:s }}|{{ include:pusher }}{{ include:prepender }}')); + $this->assertSame( + 'P|WSLOT', + $this->render('{{ stack:s }}|{{ include:wrapper }}{{ push:s }}P{{ /push:s }}SLOT{{ /include:wrapper }}') + ); + } + + public function test_sections_can_be_defined_in_a_view_and_in_slot_contents() + { + $this->viewShouldReturnRaw('definer', '{{ section:s }}FROM-VIEW{{ /section:s }}X'); + $this->viewShouldReturnRaw('wrapper', 'W{{ slot }}'); + + $this->assertSame('FROM-VIEW|X', $this->render('{{ yield:s }}|{{ include:definer }}')); + $this->assertSame( + 'FROM-SLOT|WX', + $this->render('{{ yield:s }}|{{ include:wrapper }}{{ section:s }}FROM-SLOT{{ /section:s }}X{{ /include:wrapper }}') + ); + } + + public function test_a_view_can_yield_a_section_the_caller_defined() + { + $this->viewShouldReturnRaw('w', 'W[{{ yield:s }}]'); + + $this->assertSame('W[OUTER]', $this->render('{{ section:s }}OUTER{{ /section:s }}{{ include:w }}')); + } + + public function test_once_only_renders_once_across_repeated_includes() + { + $this->viewShouldReturnRaw('p', '{{ once }}ONCE{{ /once }}X'); + $this->viewShouldReturnRaw('w', '{{ slot }}{{ slot }}'); + + $this->assertSame('ONCEXX', $this->render('{{ include:p }}{{ include:p }}')); + $this->assertSame('ONCEXX', $this->render('{{ items }}{{ include:p }}{{ /items }}', ['items' => [[], []]])); + $this->assertSame('OXX', $this->render('{{ include:w }}{{ once }}O{{ /once }}X{{ /include:w }}')); + } + + public function test_noparse_and_escaped_literals_survive_slot_contents() + { + $this->viewShouldReturnRaw('p', '{{ noparse }}{{ title }}{{ /noparse }}|{{ title }}'); + $this->viewShouldReturnRaw('w', 'W{{ slot }}'); + + $this->assertSame('{{ title }}|T', $this->render('{{ include:p title="T" }}')); + $this->assertSame('W{{ x }}', $this->render('{{ include:w }}{{ noparse }}{{ x }}{{ /noparse }}{{ /include:w }}', ['x' => 'X'])); + $this->assertSame('W{{ x }}', $this->render('{{ include:w }}@{{ x }}{{ /include:w }}', ['x' => 'X'])); + } + + public function test_recursive_nodes_work_around_inside_and_within_slots_of_an_include() + { + $this->viewShouldReturnRaw('item', '{{ t }}'); + $this->viewShouldReturnRaw('menu', '{{ nav }}[{{ title }}]{{ if children }}
    {{ *recursive children* }}
{{ /if }}{{ /nav }}'); + $this->viewShouldReturnRaw('wrapper', 'W{{ slot }}'); + + $recursive = '{{ nav }}[{{ title }}]{{ if children }}
    {{ *recursive children* }}
{{ /if }}{{ /nav }}'; + + $this->assertSame( + 'A
    A1A2
      A2a
B', + $this->render('{{ nav }}{{ include:item :t="title" }}{{ if children }}
    {{ *recursive children* }}
{{ /if }}{{ /nav }}', ['nav' => $this->tree()]) + ); + + $this->assertSame( + '[A]
    [A1][A2]
      [A2a]
[B]', + $this->render('{{ include:menu :nav="tree" }}', ['tree' => $this->tree()]) + ); + + $this->assertSame( + 'W[A]
    [A1][A2]
      [A2a]
[B]
', + $this->render('{{ include:wrapper }}'.$recursive.'{{ /include:wrapper }}', ['nav' => $this->tree()]) + ); + } + + public function test_query_builders_can_be_passed_to_an_include_without_leaking() + { + $builder = Mockery::mock(Builder::class); + $builder->shouldReceive('get')->andReturn(collect([['title' => 'Foo'], ['title' => 'Bar']])); + $builder->shouldReceive('orderBy')->andReturnSelf(); + + $this->viewShouldReturnRaw('list', '{{ rows order_by="title:desc" }}<{{ title }}>{{ /rows }}'); + $this->viewShouldReturnRaw('empty', 'E[{{ rows }}]'); + + $this->assertSame( + 'E[]', + $this->render('{{ include:list :rows="data" }}{{ include:empty }}', ['data' => $builder]) + ); + } + + public function test_augmented_values_survive_being_passed_as_parameters() + { + $this->viewShouldReturnRaw('p', '[{{ v }}][{{ v | upper }}][{{ params:v }}]'); + + $this->assertSame('[hello][HELLO][hello]', $this->render('{{ include:p :v="v" }}', ['v' => new Value('hello')])); + } + + public function test_handle_prefix_accepts_a_list_of_prefixes() + { + $this->viewShouldReturnRaw('hero', '[{{ title }}][{{ body }}]'); + + $this->assertSame('[AT][BB]', $this->render('{{ include:hero :params="d" :handle_prefix="pf" }}', [ + 'd' => ['a_title' => 'AT', 'b_body' => 'BB'], + 'pf' => ['a_', 'b_'], + ])); + + $this->viewShouldReturnRaw('both', '[{{ title }}][{{ a_title }}][{{ b_title }}]'); + + $this->assertSame('[FIRST][FIRST][SECOND]', $this->render('{{ include:both :params="d" :handle_prefix="pf" }}', [ + 'd' => ['a_title' => 'FIRST', 'b_title' => 'SECOND'], + 'pf' => ['a_', 'b_'], + ])); + } + + public function test_the_cache_tag_works_around_and_inside_an_include_with_slots() + { + $this->viewShouldReturnRaw('w', 'W{{ slot }}'); + $this->viewShouldReturnRaw('cw', '{{ cache }}W{{ slot }}{{ /cache }}'); + + $this->assertSame('WBODY', $this->render('{{ cache }}{{ include:w }}BODY{{ /include:w }}{{ /cache }}')); + $this->assertSame('WBODY', $this->render('{{ include:cw }}BODY{{ /include:cw }}')); + } + + public function test_slot_contents_do_not_see_the_views_front_matter() + { + $this->viewShouldReturnRaw('fm', "---\nk: FM\n---\n[{{ view:k }}]<{{ slot }}>"); + + $this->assertSame('[FM]', trim($this->render('{{ include:fm }}BODY{{ /include:fm }}'))); + $this->assertSame('[FM]<[]>', trim($this->render('{{ include:fm }}[{{ view:k }}]{{ /include:fm }}'))); + } +} diff --git a/tests/Antlers/Runtime/Includes/IssuesTest.php b/tests/Antlers/Runtime/Includes/IssuesTest.php new file mode 100644 index 00000000000..212d64e7533 --- /dev/null +++ b/tests/Antlers/Runtime/Includes/IssuesTest.php @@ -0,0 +1,70 @@ +withFakeViews(); + } + + private function render($template, $data = []) + { + return $this->renderString($template, $data, true); + } + + public function test_issue_8175_assigned_variables_never_leak_consistently() + { + $this->viewShouldReturnRaw('noop', ''); + $this->viewShouldReturnRaw('setter', '{{ $var = "SET" }}'); + $this->viewShouldReturnRaw('setter_extra', '{{ $var = "SET" }}{{ partial:noop }}'); + + $this->assertSame('|[]', $this->render('{{ include:setter }}|[{{ $var }}]')); + $this->assertSame('|[]', $this->render('{{ include:setter_extra }}|[{{ $var }}]')); + } + + public function test_issue_10703_params_do_not_leak_into_the_next_include() + { + $this->viewShouldReturnRaw('cardA', '[{{ class }}|{{ view:class }}]'); + $this->viewShouldReturnRaw('cardB', '[{{ class }}|{{ view:class }}]'); + + $this->assertSame( + '[cool|cool][|]', + $this->render('{{ include:cardA class="cool" }}{{ include:cardB }}') + ); + } + + public function test_issue_11486_frontmatter_does_not_leak_across_inclusions() + { + $this->viewShouldReturnRaw('inc_a', "---\nvar_a: A\n---\nA[{{ view:var_a }}]"); + $this->viewShouldReturnRaw('inc_b', "---\nvar_b: B\n---\nB[{{ view:var_b }}]{{ include:inc_a }}"); + + $template = '{{ include:inc_b }}{{ include:inc_b }}|HOME[{{ view:var_a }}|{{ view:var_b }}]'; + + $this->assertSame('B[B]A[A]B[B]A[A]|HOME[|]', $this->render($template)); + $this->assertNull(Cascade::get('views')); + } + + public function test_issue_12709_isolation_is_consistent_across_conditional_forms() + { + $this->viewShouldReturnRaw('mod', '{{ foo = "changed" }}M'); + + $this->assertSame( + 'M|orig', + $this->render('{{ foo = "orig" }}{{ if bar }}{{ include:mod }}{{ /if }}|{{ foo }}', ['bar' => true]) + ); + + $this->assertSame( + 'M|orig', + $this->render('{{ foo = "orig" }}{{ bar ?= { include:mod } }}|{{ foo }}', ['bar' => true]) + ); + } +} diff --git a/tests/Antlers/Runtime/Includes/NestedTest.php b/tests/Antlers/Runtime/Includes/NestedTest.php new file mode 100644 index 00000000000..dc3600ff117 --- /dev/null +++ b/tests/Antlers/Runtime/Includes/NestedTest.php @@ -0,0 +1,146 @@ +withFakeViews(); + } + + private function render($template, $data = []) + { + return $this->renderString($template, $data, true); + } + + public function test_three_levels_deep() + { + $this->viewShouldReturnRaw('level1', 'L1[{{ include:level2 }}]'); + $this->viewShouldReturnRaw('level2', 'L2[{{ include:level3 }}]'); + $this->viewShouldReturnRaw('level3', 'L3'); + + $this->assertSame('L1[L2[L3]]', $this->render('{{ include:level1 }}')); + } + + public function test_caller_scope_does_not_reach_any_level() + { + $this->viewShouldReturnRaw('level1', 'L1[{{ a }}]{{ include:level2 }}'); + $this->viewShouldReturnRaw('level2', 'L2[{{ a }}]'); + + $this->assertSame('L1[]L2[]', $this->render('{{ include:level1 }}', ['a' => 'caller'])); + } + + public function test_params_do_not_implicitly_flow_to_deeper_includes() + { + $this->viewShouldReturnRaw('level1', 'L1[{{ b }}]{{ include:level2 }}'); + $this->viewShouldReturnRaw('level2', 'L2[{{ b }}]'); + + $this->assertSame('L1[x]L2[]', $this->render('{{ include:level1 b="x" }}')); + } + + public function test_data_can_be_threaded_down_explicitly() + { + $this->viewShouldReturnRaw('level1', 'L1[{{ a }}]{{ include:level2 :a="a" }}'); + $this->viewShouldReturnRaw('level2', 'L2[{{ a }}]'); + + $this->assertSame('L1[passed]L2[passed]', $this->render('{{ include:level1 a="passed" }}')); + } + + public function test_same_variable_name_at_each_level_stays_isolated() + { + $this->viewShouldReturnRaw('level1', '{{ x = "1" }}{{ x }}{{ include:level2 }}{{ x }}'); + $this->viewShouldReturnRaw('level2', '{{ x = "2" }}{{ x }}'); + + $template = '{{ x = "0" }}{{ include:level1 }}{{ x }}'; + + $this->assertSame('1210', $this->render($template)); + } + + public function test_params_accessor_reflects_each_levels_own_params() + { + $this->viewShouldReturnRaw('level1', 'L1{{ params:p }}{{ include:level2 p="two" }}'); + $this->viewShouldReturnRaw('level2', 'L2{{ params:p }}'); + + $this->assertSame('L1oneL2two', $this->render('{{ include:level1 p="one" }}')); + } + + public function test_outer_slots_do_not_leak_into_a_nested_include() + { + $this->viewShouldReturnRaw('outer', '{{ slot:otitle }}{{ include:inner }}{{ slot:ititle }}INNER{{ /slot:ititle }}{{ /include:inner }}'); + $this->viewShouldReturnRaw('inner', '{{ slot:ititle }}[{{ slot:otitle }}]'); + + $template = '{{ include:outer }}{{ slot:otitle }}OUTER{{ /slot:otitle }}{{ /include:outer }}'; + + $this->assertSame('OUTERINNER[]', $this->render($template)); + } + + public function test_a_slot_with_an_include_still_sees_the_callers_scope() + { + $this->viewShouldReturnRaw('wrapper', '{{ slot }}'); + $this->viewShouldReturnRaw('inner', 'I[{{ name }}]'); + + $template = '{{ include:wrapper }}{{ include:inner :name="caller_var" }}{{ /include:wrapper }}'; + + $this->assertSame('I[CV]', $this->render($template, ['caller_var' => 'CV'])); + } + + public function test_looped_includes_keep_params_and_slots_isolated() + { + $this->viewShouldReturnRaw('row', '{{ n }}:{{ slot }}:{{ params:n }};'); + $items = collect()->range(1, 10)->map(fn ($value) => compact('value'))->all(); + $expected = collect()->range(1, 10)->map(fn ($value) => "{$value}:{$value}:{$value};")->implode(''); + + $template = '{{ items }}{{ include:row :n="value" }}{{ value }}{{ /include:row }}{{ /items }}'; + + $this->assertSame($expected, $this->render($template, ['items' => $items])); + } + + public function test_scope_is_preserved_through_alternating_view_engines() + { + Cascade::set('secret', 'cascade'); + $this->viewShouldReturnRaw('outer', 'O[{{ label }}|{{ secret }}]{{ include:middle :label="label" :rows="rows" }}'); + $this->viewShouldReturnRaw('middle', 'M[{{ $label }}|{{ $secret ?? \'\' }}][{{ $params[\'label\'] }}:{{ $value }}:{{ $secret ?? \'\' }}]', 'blade.php'); + $this->viewShouldReturnRaw('inner', 'I[{{ label }}|{{ secret }}]{{ rows }}{{ slot:item :value="value" }}{{ /rows }}'); + + $template = '{{ include:outer label="L" :rows="rows" }}'; + + $this->assertSame('O[L|]M[L|]I[L|][L:A:][L:B:]', $this->render($template, [ + 'secret' => 'caller', + 'rows' => [['value' => 'A'], ['value' => 'B']], + ])); + } + + public function test_recursive_include_with_termination() + { + $this->viewShouldReturnRaw('tree', '{{ if depth > 0 }}{{ include:tree :depth="depth|subtract:1" }}{{ /if }}'); + + $this->assertSame('', $this->render('{{ include:tree :depth="3" }}')); + } + + public function test_an_include_with_its_own_slot_can_live_inside_a_named_slot() + { + $this->viewShouldReturnRaw('card', '{{ slot:header }}'); + $this->viewShouldReturnRaw('badge', '{{ slot }}'); + + $template = '{{ include:card }}{{ slot:header }}{{ include:badge }}LBL{{ /include:badge }}{{ /slot:header }}{{ /include:card }}'; + + $this->assertSame('LBL', $this->render($template)); + } + + public function test_an_assignment_in_slot_content_does_not_leak() + { + $this->viewShouldReturnRaw('wrapper', '{{ slot }}[{{ leaked }}]'); + + $template = '{{ include:wrapper }}{{ leaked = "fromslot" }}{{ leaked }}{{ /include:wrapper }}|{{ leaked }}'; + + $this->assertSame('fromslot[]|', $this->render($template)); + } +} diff --git a/tests/Antlers/Runtime/Includes/SandboxTest.php b/tests/Antlers/Runtime/Includes/SandboxTest.php new file mode 100644 index 00000000000..960074f138f --- /dev/null +++ b/tests/Antlers/Runtime/Includes/SandboxTest.php @@ -0,0 +1,147 @@ +withFakeViews(); + } + + private function render($template, $data = []) + { + return $this->renderString($template, $data, true); + } + + public function test_an_enclosing_partials_handle_prefix_does_not_reach_the_include() + { + $this->viewShouldReturnRaw('shell', '{{ include:leaf :params="d" }}'); + $this->viewShouldReturnRaw('leaf', 'leaf[{{ title }}]'); + + $data = ['d' => ['hero_title' => 'PREFIXED']]; + + $this->assertSame('leaf[]', $this->render('{{ partial:shell handle_prefix="hero_" :d="d" }}', $data)); + $this->assertSame('leaf[]', $this->render('{{ scope:s handle_prefix="hero_" }}{{ include:leaf :params="d" }}{{ /scope:s }}', $data)); + $this->assertSame('leaf[]', $this->render('{{ include:leaf :params="d" }}', $data)); + } + + public function test_an_enclosing_handle_prefix_still_applies_to_slot_contents() + { + $this->viewShouldReturnRaw('shell', '{{ include:w }}[{{ title }}]{{ /include:w }}'); + $this->viewShouldReturnRaw('w', 'W<{{ slot }}>'); + + $this->assertSame('W<[T]>', $this->render('{{ partial:shell handle_prefix="hero_" :hero_title="t" }}', ['t' => 'T'])); + } + + public function test_a_deferred_slot_render_does_not_destroy_the_views_scope() + { + $this->viewShouldReturnRaw('outer', '[pre={{ av }}]<{{ slot }}>[post={{ av }}][params={{ params:av }}]'); + $this->viewShouldReturnRaw('inner', 'I<{{ slot }}>'); + + $this->assertSame( + '[pre=AV]>[post=AV][params=AV]', + $this->render('{{ include:outer av="AV" }}{{ include:inner }}X{{ /include:inner }}{{ /include:outer }}') + ); + + $this->assertSame( + '[pre=AV]>[post=AV][params=AV]', + $this->render('{{ include:outer av="AV" }}{{ partial:inner }}X{{ /partial:inner }}{{ /include:outer }}') + ); + } + + public function test_a_partial_inside_an_include_cannot_see_the_outer_caller_scope() + { + $this->viewShouldReturnRaw('shell', 'S[{{ p }}]{{ partial:inner }}'); + $this->viewShouldReturnRaw('inner', 'P[{{ p }}]'); + + $this->assertSame( + 'S[param]P[param]', + $this->render('{{ include:shell p="param" }}', ['p' => 'caller-p']) + ); + } + + public function test_a_partials_assignment_cannot_escape_the_include_boundary() + { + $this->viewShouldReturnRaw('shell', '{{ partial:setter }}IN[{{ v }}]'); + $this->viewShouldReturnRaw('setter', '{{ v = "from-partial" }}'); + + $this->assertSame( + 'IN[]|OUT[caller]', + $this->render('{{ v = "caller" }}{{ include:shell }}|OUT[{{ v }}]') + ); + } + + public function test_the_internal_slot_carrier_key_is_not_exposed_to_the_view() + { + $this->viewShouldReturnRaw('v', 'C[{{ __statamic_include_slots }}]'); + + $this->assertSame('C[]', $this->render('{{ include:v }}body{{ /include:v }}')); + } + + public function test_mutating_a_passed_array_does_not_affect_the_caller() + { + $this->viewShouldReturnRaw('mut', '{{ data:key = "mutated" }}IN[{{ data:key }}]'); + + $this->assertSame( + 'IN[mutated]|OUT[original]', + $this->render('{{ include:mut :data="data" }}|OUT[{{ data:key }}]', ['data' => ['key' => 'original']]) + ); + } + + public function test_mutating_a_passed_object_does_not_affect_the_caller() + { + $obj = new \stdClass(); + $obj->prop = 'original'; + + $this->viewShouldReturnRaw('omut', '{{ o:prop = "mutated" }}IN[{{ o:prop }}]'); + + $result = $this->render('{{ include:omut :o="o" }}|OUT[{{ o:prop }}]', ['o' => $obj]); + + $this->assertSame('IN[]|OUT[original]', $result); + $this->assertSame('original', $obj->prop, 'The underlying PHP object must not be mutated.'); + } + + public function test_self_closing_slot_output_avoids_same_name_pairing() + { + $this->viewShouldReturnRaw('outer', '{{ slot:title /}}{{ include:inner }}{{ slot:title }}INNER{{ /slot:title }}{{ /include:inner }}'); + $this->viewShouldReturnRaw('inner', '{{ slot:title /}}'); + + $template = '{{ include:outer }}{{ slot:title }}OUTER{{ /slot:title }}{{ /include:outer }}'; + + $this->assertSame('OUTERINNER', $this->render($template)); + } + + public function test_slot_content_cannot_see_include_internal_variables() + { + $this->viewShouldReturnRaw('w', '{{ internal = "secret" }}{{ slot }}'); + + $this->assertSame('[]', $this->render('{{ include:w }}[{{ internal }}]{{ /include:w }}')); + } + + public function test_scope_tag_writes_are_visible_outside_the_include() + { + $this->viewShouldReturnRaw('writer', '{{ scope:smuggled }}{{ secret }}{{ /scope:smuggled }}W'); + + $this->assertSame( + 'SW|CALLER[S]', + $this->render('{{ include:writer secret="S" }}|CALLER[{{ smuggled:secret }}]') + ); + } + + public function test_a_slot_that_escapes_the_include_can_still_render_afterwards() + { + $this->viewShouldReturnRaw('w', '{{ scope:smuggled }}W{{ /scope:smuggled }}'); + + $this->assertSame( + 'W|BODY:O', + $this->render('{{ include:w }}BODY:{{ outer }}{{ /include:w }}|{{ smuggled:slot }}', ['outer' => 'O']) + ); + } +} diff --git a/tests/Antlers/Runtime/Includes/SlotsTest.php b/tests/Antlers/Runtime/Includes/SlotsTest.php new file mode 100644 index 00000000000..456e2e54c92 --- /dev/null +++ b/tests/Antlers/Runtime/Includes/SlotsTest.php @@ -0,0 +1,253 @@ +withFakeViews(); + } + + private function render($template, $data = []) + { + return $this->renderString($template, $data, true); + } + + private $spy; + + private function registerSpyTag(): void + { + $this->spy = new class extends Tags + { + public static $handle = 'spy'; + + public static $count = 0; + + public function index() + { + self::$count++; + + return ''; + } + }; + + $this->spy::$count = 0; + $this->spy::register(); + } + + public function test_default_slot() + { + $this->viewShouldReturnRaw('wrapper', '
{{ slot }}
'); + + $this->assertSame('
Body
', $this->render('{{ include:wrapper }}Body{{ /include:wrapper }}')); + } + + public function test_a_default_slot_may_be_passed_inline_as_a_param() + { + $this->viewShouldReturnRaw('greeting', '
{{ slot }}
'); + + $this->assertSame('
Hello
', $this->render('{{ include:greeting slot="Hello" }}')); + } + + public function test_if_slot_is_false_when_no_body_is_given() + { + $this->viewShouldReturnRaw('wrapper', '{{ if slot }}HAS{{ else }}NONE{{ /if }}'); + + $this->assertSame('NONE', $this->render('{{ include:wrapper }}{{ /include:wrapper }}')); + $this->assertSame('NONE', $this->render('{{ include:wrapper }}')); + $this->assertSame('NONE', $this->render('{{ include:wrapper }} {{ /include:wrapper }}')); + } + + public function test_default_slot_presence_does_not_bleed_between_includes() + { + $this->viewShouldReturnRaw('wrapper', '{{ if slot }}HAS{{ else }}NONE{{ /if }}'); + + $this->assertSame( + 'HAS|NONE', + $this->render('{{ include:wrapper }}body{{ /include:wrapper }}|{{ include:wrapper }}{{ /include:wrapper }}') + ); + } + + public function test_slot_sees_outer_scope_but_the_view_does_not() + { + $this->viewShouldReturnRaw('wrapper', '{{ title }}{{ slot }}'); + + $template = '{{ include:wrapper }}{{ title }}{{ /include:wrapper }}'; + + $this->assertSame('Caller', $this->render($template, ['title' => 'Caller'])); + } + + public function test_slot_content_with_text_around_a_pair_is_preserved() + { + $this->viewShouldReturnRaw('wrapper', '{{ slot }}'); + + $tpl = '{{ include:wrapper }}before{{ if show }}mid{{ /if }}after{{ /include:wrapper }}'; + + $this->assertSame('beforemidafter', $this->render($tpl, ['show' => true])); + } + + public function test_a_slot_containing_only_a_pair_is_considered_present() + { + $this->viewShouldReturnRaw('wrapper', '{{ if slot }}HAS[{{ slot }}]{{ else }}NONE{{ /if }}'); + + $tpl = '{{ include:wrapper }}{{ if show }}X{{ /if }}{{ /include:wrapper }}'; + + $this->assertSame('HAS[X]', $this->render($tpl, ['show' => true])); + $this->assertSame('HAS[]', $this->render($tpl, ['show' => false])); + } + + public function test_slot_content_can_access_include_params() + { + $this->viewShouldReturnRaw('wrapper', '
{{ slot }}
'); + + $template = '{{ include:wrapper :params="data" handle_prefix="card_" foo="named" }}{{ params:foo }}|{{ params:title }}{{ /include:wrapper }}'; + + $this->assertSame('
named|Title
', $this->render($template, [ + 'data' => ['foo' => 'spread', 'card_title' => 'Title'], + ])); + } + + public function test_named_slots() + { + $this->viewShouldReturnRaw('card', '{{ slot:header }}{{ slot }}'); + + $template = '{{ include:card }}{{ slot:header }}Title{{ /slot:header }}Body{{ /include:card }}'; + + $this->assertSame('TitleBody', $this->render($template)); + } + + public function test_named_slots_do_not_replace_same_named_params() + { + $this->viewShouldReturnRaw('card', '[{{ header }}][{{ slot:header }}][{{ params:header }}]'); + $this->viewShouldReturnRaw('card_b', '[{{ $header }}][][{{ $params[\'header\'] }}]', 'blade.php'); + + $antlers = '{{ include:card header="Data" }}{{ slot:header }}Slot{{ /slot:header }}{{ /include:card }}'; + $blade = 'Slot'; + + $this->assertSame('[Data][Slot][Data]', $this->render($antlers)); + $this->assertSame('[Data][Slot][Data]', Blade::render($blade)); + } + + public function test_a_named_slot_falls_back_to_the_views_default_when_not_provided() + { + $this->viewShouldReturnRaw('card', '{{ if slot:header }}{{ slot:header }}{{ else }}Default{{ /if }}'); + + $this->assertSame('Default', $this->render('{{ include:card }}Body{{ /include:card }}')); + $this->assertSame('Provided', $this->render('{{ include:card }}{{ slot:header }}Provided{{ /slot:header }}{{ /include:card }}')); + } + + public function test_named_slot_presence_is_false_when_empty() + { + $this->viewShouldReturnRaw('card', '{{ if slot:header }}YES{{ else }}NO{{ /if }}'); + + $this->assertSame('NO', $this->render('{{ include:card }}{{ slot:header }} {{ /slot:header }}{{ /include:card }}')); + } + + public function test_scoped_slot_exposes_multiple_props() + { + $this->viewShouldReturnRaw('row', '{{ slot:row :label="title" :n="num" }}'); + + $template = '{{ include:row title="T" num="3" }}{{ slot:row }}[{{ label }}|{{ n }}]{{ /slot:row }}{{ /include:row }}'; + + $this->assertSame('[T|3]', $this->render($template)); + } + + public function test_a_scoped_slot_is_rendered_for_each_iteration_of_a_loop() + { + $this->viewShouldReturnRaw('list', '{{ rows }}<{{ slot:row :label="value" :i="count" }}>{{ /rows }}'); + + $template = '{{ include:list :rows="data" }}{{ slot:row }}{{ label }}#{{ i }}{{ /slot:row }}{{ /include:list }}'; + + $this->assertSame('', $this->render($template, ['data' => [['value' => 'a'], ['value' => 'b']]])); + } + + public function test_scoped_slot_props_combine_with_caller_scope() + { + $this->viewShouldReturnRaw('combo', '{{ slot:item :label="heading" }}'); + + $template = '{{ include:combo heading="VIEW" }}{{ slot:item }}[{{ label }}|{{ outer }}]{{ /slot:item }}{{ /include:combo }}'; + + $this->assertSame('[VIEW|OUT]', $this->render($template, ['outer' => 'OUT'])); + } + + public function test_scoped_slot_props_override_caller_variables() + { + $this->viewShouldReturnRaw('clash', '{{ slot:item :name="inner" }}'); + + $template = '{{ include:clash inner="FROM-VIEW" }}{{ slot:item }}[{{ name }}]{{ /slot:item }}{{ /include:clash }}'; + + $this->assertSame('[FROM-VIEW]', $this->render($template, ['name' => 'FROM-CALLER'])); + } + + public function test_unused_slots_are_not_rendered() + { + $this->registerSpyTag(); + $this->viewShouldReturnRaw('wrapper', 'no slot output'); + + $template = '{{ include:wrapper }}{{ spy }}{{ /include:wrapper }}'; + + $this->assertSame('no slot output', $this->render($template)); + $this->assertSame(0, $this->spy::$count); + } + + public function test_a_slot_is_rendered_each_time_it_is_output() + { + $this->registerSpyTag(); + $this->viewShouldReturnRaw('wrapper', '{{ slot }}{{ slot }}'); + + $template = '{{ include:wrapper }}{{ spy }}{{ /include:wrapper }}'; + + $this->render($template); + + $this->assertSame(2, $this->spy::$count); + } + + public function test_a_condition_checks_slot_presence_without_rendering_it() + { + $this->registerSpyTag(); + $this->viewShouldReturnRaw('wrapper', '{{ if slot }}HAS{{ else }}NONE{{ /if }}'); + + $this->assertSame('HAS', $this->render('{{ include:wrapper }}{{ spy }}{{ /include:wrapper }}')); + $this->assertSame(0, $this->spy::$count); + } + + public function test_a_scoped_slot_guarded_by_a_condition_renders_only_once_with_its_props() + { + $this->registerSpyTag(); + $this->viewShouldReturnRaw('list', '{{ if slot:row }}{{ slot:row :label="heading" }}{{ /if }}'); + + $template = '{{ include:list heading="H" }}{{ slot:row }}{{ spy }}[{{ label }}]{{ /slot:row }}{{ /include:list }}'; + + $this->assertSame('[H]', $this->render($template)); + $this->assertSame(1, $this->spy::$count); + } + + public function test_antlers_slots_can_be_rendered_by_blade_views() + { + $this->viewShouldReturnRaw('list', '{{ $title }}@foreach($rows as $row)@endforeach', 'blade.php'); + + $template = '{{ include:list :rows="rows" }}{{ slot:title }}Title{{ /slot:title }}{{ slot:item }}[{{ label }}]{{ /slot:item }}{{ /include:list }}'; + + $this->assertSame('Title[A][B]', $this->render($template, ['rows' => ['A', 'B']])); + } + + public function test_blade_slots_can_be_rendered_by_antlers_views() + { + $this->viewShouldReturnRaw('list', '{{ rows }}{{ slot:item :label="value" }}{{ /rows }}[{{ params:item }}]'); + + $template = '[{{ $label }}{{ $params[\'title\'] }}]'; + + $this->assertSame('[AT][BT][]', Blade::render($template, [ + 'rows' => [['value' => 'A'], ['value' => 'B']], + ])); + } +} diff --git a/tests/View/Blade/AntlersComponents/IncludeCompilerTest.php b/tests/View/Blade/AntlersComponents/IncludeCompilerTest.php new file mode 100644 index 00000000000..cd5014a178f --- /dev/null +++ b/tests/View/Blade/AntlersComponents/IncludeCompilerTest.php @@ -0,0 +1,358 @@ +withFakeViews(); + $this->artisan('view:clear'); + } + + #[Test] + public function it_compiles_include_tags() + { + $this->viewShouldReturnRaw('alert', '
{{ $title }}
', 'blade.php'); + + $expected = '
The Title
'; + + $this->assertSame($expected, Blade::render('')); + $this->assertSame($expected, Blade::render('')); + } + + #[Test] + public function it_does_not_capture_the_caller_scope() + { + $this->viewShouldReturnRaw('alert', '[{{ $secret ?? "none" }}][{{ $passed ?? "none" }}]'); + + $this->assertSame( + '[none][yes]', + Blade::render('', ['secret' => 'LEAK']) + ); + } + + #[Test] + public function it_compiles_slots() + { + $this->viewShouldReturnRaw('alert', '
{{ $slot }}
'); + + $template = <<<'BLADE' + + I am the slot content. + +BLADE; + + $this->assertSame('
I am the slot content.
', Blade::render($template)); + $this->assertSame( + '
Title
', + Blade::render('{{ $params[\'title\'] }}') + ); + } + + #[Test] + public function slot_content_sees_the_caller_scope() + { + $this->viewShouldReturnRaw('alert', '
{{ $slot }}
'); + + $this->assertSame( + '
LEAK
', + Blade::render('{{ $secret }}', ['secret' => 'LEAK']) + ); + } + + #[Test] + public function it_compiles_named_slots() + { + $alert = <<<'ALERT' + +
{{ $slot }}
+ +ALERT; + $this->viewShouldReturnRaw('alert', $alert, 'blade.php'); + + $template = <<<'BLADE' + + The header + The footer + I am the slot content. + +BLADE; + + $expected = <<<'EXPECTED' + +
I am the slot content.
+ +EXPECTED; + + $this->assertSame($expected, Blade::render($template)); + } + + #[Test] + public function it_compiles_scoped_slots() + { + $this->viewShouldReturnRaw('list', "@foreach(\$rows as \$person)iteration\" />@endforeach", 'blade.php'); + + $template = <<<'BLADE' + + [{{ $name }}#{{ $index }}] + +BLADE; + + $this->assertSame( + '[Alice#1][Bob#2]', + Blade::render($template, ['people' => [['name' => 'Alice'], ['name' => 'Bob']]]) + ); + } + + #[Test] + public function a_named_slot_can_be_output_with_the_slot_tag() + { + $this->viewShouldReturnRaw('card', '
', 'blade.php'); + + $this->assertSame( + '
Hi
', + Blade::render('Hi') + ); + $this->assertSame('
', Blade::render('')); + } + + #[Test] + public function it_forwards_exists_method_calls() + { + $template = 'Yes'; + + $this->assertSame('', Blade::render($template)); + + $this->viewShouldReturnRaw('alert', 'some content'); + + $this->assertSame('Yes', Blade::render($template)); + } + + #[Test] + public function it_forwards_if_exists_method_calls() + { + $template = ''; + + $this->assertSame('', Blade::render($template)); + + $this->viewShouldReturnRaw('alert', 'some content'); + + $this->assertSame('some content', Blade::render($template)); + } + + #[Test] + public function it_compiles_when_parameter() + { + $this->viewShouldReturnRaw('the_partial', 'The content'); + + $template = ''; + + $this->assertSame('', Blade::render($template, ['theValue' => false])); + $this->assertSame('The content', Blade::render($template, ['theValue' => true])); + } + + #[Test] + public function it_compiles_unless_parameter() + { + $this->viewShouldReturnRaw('the_partial', 'The content'); + + $template = ''; + + $this->assertSame('', Blade::render($template, ['theValue' => true])); + $this->assertSame('The content', Blade::render($template, ['theValue' => false])); + } + + #[Test] + public function it_isolates_the_caller_scope_through_nesting() + { + $this->viewShouldReturnRaw('outer', 'O[{{ $a ?? "none" }}]', 'blade.php'); + $this->viewShouldReturnRaw('inner', 'I[{{ $a ?? "none" }}]', 'blade.php'); + + $this->assertSame('O[none]I[none]', Blade::render('', ['a' => 'CALLER'])); + } + + #[Test] + public function a_param_does_not_leak_into_the_next_include() + { + $this->viewShouldReturnRaw('card', 'C[{{ $class ?? "none" }}]', 'blade.php'); + + $this->assertSame( + 'C[cool]C[none]', + Blade::render('') + ); + } + + #[Test] + public function an_assignment_inside_an_include_does_not_leak_to_a_sibling() + { + $this->viewShouldReturnRaw('setter', '{{ v = "set" }}S'); + $this->viewShouldReturnRaw('getter', 'G[{{ v ?? "none" }}]'); + + $this->assertSame('SG[none]', Blade::render('')); + } + + #[Test] + public function it_compiles_nested_includes() + { + $this->viewShouldReturnRaw('one', 'Just Some Text'); + $this->viewShouldReturnRaw('two', '{{ $slot }}'); + + $template = <<<'BLADE' + + + +BLADE; + + $this->assertSame('Just Some Text', trim(Blade::render($template))); + } + + #[Test] + public function slot_tags_still_compile_outside_includes() + { + (new class extends Tags + { + protected static $handle = 'slot'; + + public function wildcard($tag) + { + return 'custom'; + } + })::register(); + + $this->assertSame('custom', Blade::render('', ['header' => 'data'])); + + $this->assertSame('custom', Blade::render('')); + } + + #[Test] + public function invalid_slot_names_are_rejected() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid slot name [bad-name]'); + + Blade::render('Bad'); + } + + #[Test] + public function a_slot_does_not_replace_the_variables_the_include_provides() + { + $this->viewShouldReturnRaw('card', "@frontmatter(['fm' => 'F'])[{{ \$params['title'] }}][{{ \$view['fm'] }}][][]", 'blade.php'); + + $template = <<<'BLADE' + + P + V + +BLADE; + + $this->assertSame('[T][F][P][V]', trim(Blade::render($template))); + } + + #[Test] + public function a_partial_inside_an_include_resolves_its_own_slots() + { + $this->viewShouldReturnRaw('card', 'FromPartial', 'blade.php'); + $this->viewShouldReturnRaw('badge', '[{{ $label }}]', 'blade.php'); + + $this->assertSame( + '[FromPartial]', + Blade::render('FromInclude') + ); + } + + #[Test] + public function the_if_exists_default_slot_is_lazy() + { + $spy = new class extends Tags + { + protected static $handle = 'if_exists_spy'; + + public static $count = 0; + + public function index() + { + self::$count++; + + return 'SPY'; + } + }; + $spy::register(); + + $this->viewShouldReturnRaw('ignores_slot', 'Card', 'blade.php'); + $this->viewShouldReturnRaw('uses_slot', 'Card[{{ $slot }}]', 'blade.php'); + + $spy::$count = 0; + $this->assertSame('Card', Blade::render('')); + $this->assertSame(0, $spy::$count); + + $spy::$count = 0; + $this->assertSame('Card[SPY]', Blade::render('')); + $this->assertSame(1, $spy::$count); + } + + #[Test] + public function unused_default_slots_are_not_rendered() + { + $spy = new class extends Tags + { + protected static $handle = 'include_spy'; + + public static $count = 0; + + public function index() + { + self::$count++; + + return ''; + } + }; + + $spy::register(); + $this->viewShouldReturnRaw('card', 'Card', 'blade.php'); + + $this->assertSame('Card', Blade::render('')); + $this->assertSame(0, $spy::$count); + } + + #[Test] + public function slots_named_after_framework_variables_are_not_aliased() + { + $this->viewShouldReturnRaw('card_env', "@forelse ([1] as \$i)\nok\n@empty\nx\n@endforelse\n[]", 'blade.php'); + + $this->assertSame('ok [E]', Str::squish(Blade::render('E'))); + } + + #[Test] + public function slot_content_containing_the_hoisted_nowdoc_terminator_compiles() + { + $this->viewShouldReturnRaw('alert', '
{{ $slot }}
', 'blade.php'); + + $this->assertSame( + "
before\nCOMPILED;\nafter
", + Blade::render("before\nCOMPILED;\nafter") + ); + } + + #[Test] + public function a_whitespace_only_body_is_not_a_slot() + { + $this->viewShouldReturnRaw('wrapper', '{{ if slot }}HAS{{ else }}NONE{{ /if }}'); + + $this->assertSame('NONE', Blade::render("\n \n")); + } +} From 97c6734a654a6bee309a959bdd8175b36d02ecec Mon Sep 17 00:00:00 2001 From: John Koster Date: Thu, 13 Aug 2026 22:12:32 -0500 Subject: [PATCH 03/12] Blade parity w/ Cascade isolation --- src/Tags/IncludeTag.php | 12 ++++++++++++ .../Antlers/Language/Runtime/NodeProcessor.php | 12 ------------ tests/Antlers/Runtime/Includes/CascadeTest.php | 16 ++++++++++++++++ 3 files changed, 28 insertions(+), 12 deletions(-) diff --git a/src/Tags/IncludeTag.php b/src/Tags/IncludeTag.php index 0584b8de25b..f368fa94a26 100644 --- a/src/Tags/IncludeTag.php +++ b/src/Tags/IncludeTag.php @@ -97,11 +97,23 @@ protected function render($view) $hadViews = array_key_exists('views', $cascade); $viewsState = $cascade['views'] ?? null; + // Suspended here rather than in the runtime's isolation so Blade-invoked includes are + // isolated too. Other isolated tags inheriting handle prefixes is technically + // unintentional, but preserved for BC. This may change in the next major version. + $suspendedCascade = GlobalRuntimeState::$isCascadeEnabled; + $suspendedPrefixes = GlobalRuntimeState::$prefixState; + + GlobalRuntimeState::$isCascadeEnabled = false; + GlobalRuntimeState::$prefixState = []; + try { return $view->with($scope) ->withoutExtractions() ->render(); } finally { + GlobalRuntimeState::$isCascadeEnabled = $suspendedCascade; + GlobalRuntimeState::$prefixState = $suspendedPrefixes; + if ($hadViews) { Cascade::set('views', $viewsState); } elseif (Cascade::get('views') !== null) { diff --git a/src/View/Antlers/Language/Runtime/NodeProcessor.php b/src/View/Antlers/Language/Runtime/NodeProcessor.php index 4994c3e5ded..17a7fa4bbd1 100644 --- a/src/View/Antlers/Language/Runtime/NodeProcessor.php +++ b/src/View/Antlers/Language/Runtime/NodeProcessor.php @@ -1632,7 +1632,6 @@ public function reduce($processNodes) $suspendedData = null; $capturedRuntimeState = null; - $suspendedPrefixes = null; if ($tag::$isolated) { $tag->setIsolatedContext($tagActiveData); @@ -1641,13 +1640,6 @@ public function reduce($processNodes) $capturedRuntimeState = GlobalRuntimeState::captureAndIsolate(); } - // Other isolated tags inheriting handle prefixes is technically unintentional, - // but preserved for BC. This may change in the next major version. - if ($node->name->name == 'include') { - $suspendedPrefixes = GlobalRuntimeState::$prefixState; - GlobalRuntimeState::$prefixState = []; - } - if (in_array(CachesOutput::class, class_implements($tag))) { $isCacheTag = true; GlobalRuntimeState::$isCacheEnabled = true; @@ -1688,10 +1680,6 @@ public function reduce($processNodes) GlobalRuntimeState::restoreState($capturedRuntimeState); } - - if ($suspendedPrefixes !== null) { - GlobalRuntimeState::$prefixState = $suspendedPrefixes; - } } $afterAssignments = $this->runtimeAssignments; diff --git a/tests/Antlers/Runtime/Includes/CascadeTest.php b/tests/Antlers/Runtime/Includes/CascadeTest.php index 0e545376496..de4908632bd 100644 --- a/tests/Antlers/Runtime/Includes/CascadeTest.php +++ b/tests/Antlers/Runtime/Includes/CascadeTest.php @@ -126,6 +126,22 @@ public function test_blade_includes_reach_the_cascade_when_they_ask_to() $this->assertSame('B[C]', Blade::render('')); } + public function test_a_blade_invoked_antlers_view_cannot_see_the_cascade() + { + $this->viewShouldReturnRaw('a', '[{{ cval }}]'); + + $this->assertSame('[]', Blade::render('')); + $this->assertSame('[C]', Blade::render('')); + } + + public function test_an_include_inside_a_blade_view_cannot_see_the_cascade() + { + $this->viewShouldReturnRaw('shell', '|', 'blade.php'); + $this->viewShouldReturnRaw('a', '[{{ cval }}]'); + + $this->assertSame('[]|[C]', view('shell')->render()); + } + public function test_runtime_state_survives_an_exception_thrown_inside_an_include() { (new class extends Tags From 35c344cb2e27b1770a8f45e92e19e81deff73297 Mon Sep 17 00:00:00 2001 From: John Koster Date: Thu, 13 Aug 2026 22:12:51 -0500 Subject: [PATCH 04/12] Codify neat scope trick --- tests/Antlers/Runtime/Includes/SandboxTest.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/Antlers/Runtime/Includes/SandboxTest.php b/tests/Antlers/Runtime/Includes/SandboxTest.php index 960074f138f..9e7aede6264 100644 --- a/tests/Antlers/Runtime/Includes/SandboxTest.php +++ b/tests/Antlers/Runtime/Includes/SandboxTest.php @@ -2,6 +2,7 @@ namespace Tests\Antlers\Runtime\Includes; +use Statamic\Facades\Cascade; use Tests\Antlers\ParserTestCase; use Tests\FakesViews; @@ -133,15 +134,16 @@ public function test_scope_tag_writes_are_visible_outside_the_include() 'SW|CALLER[S]', $this->render('{{ include:writer secret="S" }}|CALLER[{{ smuggled:secret }}]') ); + $this->assertSame('S', Cascade::get('smuggled')['secret']); } public function test_a_slot_that_escapes_the_include_can_still_render_afterwards() { - $this->viewShouldReturnRaw('w', '{{ scope:smuggled }}W{{ /scope:smuggled }}'); + $this->viewShouldReturnRaw('w', '{{ internal = "view-secret" }}{{ scope:smuggled }}W{{ /scope:smuggled }}'); $this->assertSame( - 'W|BODY:O', - $this->render('{{ include:w }}BODY:{{ outer }}{{ /include:w }}|{{ smuggled:slot }}', ['outer' => 'O']) + 'W|LATER[BODY:O:]', + $this->render('{{ include:w }}BODY:{{ outer }}:{{ internal }}{{ /include:w }}|LATER[{{ smuggled:slot }}]', ['outer' => 'O']) ); } } From 3582de957dd56fb3f9014d5e9b089eb45dce7c6c Mon Sep 17 00:00:00 2001 From: John Koster Date: Fri, 14 Aug 2026 22:26:32 -0500 Subject: [PATCH 05/12] Preserve spread keys named after control params Review feedback pointed out that spread() silently dropped any :params key named after a tag option (src, when, unless, cascade, params, handle_prefix), so spreading an entry with a src field lost it with no warning. None of the control params are ever read from the spread, so the except() was not protecting anything, and it was inconsistent with prefix aliasing, which happily produced those same names. Spread keys now always become view data, and tag options only ever come from parameters set on the tag itself. --- src/Tags/IncludeTag.php | 2 +- .../Runtime/Includes/IncludeTagTest.php | 30 ++++++++++++++++--- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/src/Tags/IncludeTag.php b/src/Tags/IncludeTag.php index f368fa94a26..ba628221b2e 100644 --- a/src/Tags/IncludeTag.php +++ b/src/Tags/IncludeTag.php @@ -236,7 +236,7 @@ protected function spread(mixed $spread): array throw new RuntimeException('The [params] parameter on the include tag must be an associative array.'); } - return Arr::except($spread, self::CONTROL); + return $spread; } protected function unprefixedAliases(array $data, mixed $prefixes): array diff --git a/tests/Antlers/Runtime/Includes/IncludeTagTest.php b/tests/Antlers/Runtime/Includes/IncludeTagTest.php index bbe3e2fde12..bc8e0750451 100644 --- a/tests/Antlers/Runtime/Includes/IncludeTagTest.php +++ b/tests/Antlers/Runtime/Includes/IncludeTagTest.php @@ -110,13 +110,35 @@ public function test_params_accessor_returns_the_merged_params() ); } - public function test_meta_params_never_appear_as_data() + public function test_control_params_set_on_the_tag_never_appear_as_data() { - $this->viewShouldReturnRaw('card', '[{{ params:src }}][{{ params:when }}][{{ params:handle_prefix }}][{{ params:params }}]'); + $this->viewShouldReturnRaw('card', '[{{ handle_prefix }}][{{ when }}][{{ params:handle_prefix }}][{{ params:when }}][{{ params:params }}]'); $this->assertSame( - '[][][][]', - $this->render('{{ include:card :params="data" handle_prefix="x_" }}', ['data' => ['src' => 'sneaky', 'when' => 'sneaky']]) + '[][][][][]', + $this->render('{{ include:card :params="data" handle_prefix="x_" when="true" }}', ['data' => ['title' => 'T']]) + ); + } + + public function test_spread_keys_named_after_control_params_are_preserved_as_data() + { + $this->viewShouldReturnRaw('card', '[{{ src }}][{{ params:src }}][{{ when }}][{{ params:when }}]'); + + $this->assertSame( + '[/img.jpg][/img.jpg][W][W]', + $this->render('{{ include:card :params="data" }}', ['data' => ['src' => '/img.jpg', 'when' => 'W']]) + ); + } + + public function test_an_entry_with_a_src_field_can_be_spread_into_an_include() + { + $this->viewShouldReturnRaw('card', ''); + + $entry = ['title' => 'My Video', 'src' => 'https://example.com/video.mp4']; + + $this->assertSame( + '', + $this->render('{{ include:card :params="entry" }}', ['entry' => $entry]) ); } From d759235a67ec92aa9e8c818360bf93d325dcb9ab Mon Sep 17 00:00:00 2001 From: John Koster Date: Fri, 14 Aug 2026 22:27:41 -0500 Subject: [PATCH 06/12] Apply piped modifiers to a slot's rendered content Review feedback caught that piping a slot through modifiers skipped the modifier chain entirely, while the parameters were absorbed into the slot's render data. Filtering parameters through the modifier registry was considered and rejected: prop names would become hostage to which modifiers happen to be registered (a :title prop collides with the title modifier, and any addon registering a modifier would silently change which props reach slots). The rule is now explicit and the same for default and named slots: every parameter on a slot output is a prop, and modifiers are applied by piping. The runtime coerces a Slot to its rendered content at the start of a modifier chain, so chains behave exactly as they would on any string variable, and slots are terminal values during reduction so props are never misread as modifier arguments. --- .../Language/Runtime/ModifierManager.php | 5 ++++ .../Language/Runtime/PathDataManager.php | 3 +- .../Antlers/Runtime/Includes/SandboxTest.php | 10 +++++++ tests/Antlers/Runtime/Includes/SlotsTest.php | 29 +++++++++++++++++++ 4 files changed, 46 insertions(+), 1 deletion(-) diff --git a/src/View/Antlers/Language/Runtime/ModifierManager.php b/src/View/Antlers/Language/Runtime/ModifierManager.php index 8f7871555c7..21029e8d15c 100644 --- a/src/View/Antlers/Language/Runtime/ModifierManager.php +++ b/src/View/Antlers/Language/Runtime/ModifierManager.php @@ -12,6 +12,7 @@ use Statamic\View\Antlers\Language\Nodes\Modifiers\ModifierChainNode; use Statamic\View\Antlers\Language\Nodes\Parameters\ParameterNode; use Statamic\View\Antlers\Language\Runtime\Sandbox\Environment; +use Statamic\View\Slot; class ModifierManager { @@ -104,6 +105,10 @@ public static function evaluate($value, Environment $env, ModifierChainNode $mod return null; } + if ($value instanceof Slot) { + $value = (string) $value; + } + $returnValue = $value; foreach ($modifierChain->modifierChain as $chain) { diff --git a/src/View/Antlers/Language/Runtime/PathDataManager.php b/src/View/Antlers/Language/Runtime/PathDataManager.php index 142535994e8..24831b17be8 100644 --- a/src/View/Antlers/Language/Runtime/PathDataManager.php +++ b/src/View/Antlers/Language/Runtime/PathDataManager.php @@ -33,6 +33,7 @@ use Statamic\View\Antlers\Language\Runtime\Sandbox\RuntimeValues; use Statamic\View\Antlers\Language\Utilities\StringUtilities; use Statamic\View\Cascade; +use Statamic\View\Slot; class PathDataManager { @@ -1131,7 +1132,7 @@ public static function reduceForAntlers($value, Parser $parser, $data, $isPair = GlobalRuntimeState::$isEvaluatingUserData = true; GlobalRuntimeState::$isEvaluatingData = true; - if ($value instanceof Model) { + if ($value instanceof Model || $value instanceof Slot) { GlobalRuntimeState::$isEvaluatingUserData = $prevIsEvaluatingUserData; GlobalRuntimeState::$isEvaluatingData = $prevIsEvaluatingData; diff --git a/tests/Antlers/Runtime/Includes/SandboxTest.php b/tests/Antlers/Runtime/Includes/SandboxTest.php index 9e7aede6264..722c5950211 100644 --- a/tests/Antlers/Runtime/Includes/SandboxTest.php +++ b/tests/Antlers/Runtime/Includes/SandboxTest.php @@ -137,6 +137,16 @@ public function test_scope_tag_writes_are_visible_outside_the_include() $this->assertSame('S', Cascade::get('smuggled')['secret']); } + public function test_an_escaped_slot_can_receive_props() + { + $this->viewShouldReturnRaw('define', '{{ scope:snippet }}{{# captured #}}{{ /scope:snippet }}'); + + $this->assertSame( + 'Hello, Alice! Hello, Bob!', + $this->render('{{ include:define }}Hello, {{ name }}!{{ /include:define }}{{ snippet:slot name="Alice" }} {{ snippet:slot name="Bob" }}') + ); + } + public function test_a_slot_that_escapes_the_include_can_still_render_afterwards() { $this->viewShouldReturnRaw('w', '{{ internal = "view-secret" }}{{ scope:smuggled }}W{{ /scope:smuggled }}'); diff --git a/tests/Antlers/Runtime/Includes/SlotsTest.php b/tests/Antlers/Runtime/Includes/SlotsTest.php index 456e2e54c92..fb643d1e049 100644 --- a/tests/Antlers/Runtime/Includes/SlotsTest.php +++ b/tests/Antlers/Runtime/Includes/SlotsTest.php @@ -152,6 +152,35 @@ public function test_named_slot_presence_is_false_when_empty() $this->assertSame('NO', $this->render('{{ include:card }}{{ slot:header }} {{ /slot:header }}{{ /include:card }}')); } + public function test_pipe_modifiers_can_be_applied_to_slots() + { + $this->viewShouldReturnRaw('wrapper', '<{{ slot | upper }}>'); + $this->viewShouldReturnRaw('card', '<{{ slot:header | upper }}>'); + $this->viewShouldReturnRaw('chained', '<{{ slot:header | reverse | upper }}>'); + + $this->assertSame('', $this->render('{{ include:wrapper }}body{{ /include:wrapper }}')); + $this->assertSame('', $this->render('{{ include:card }}{{ slot:header }}head{{ /slot:header }}{{ /include:card }}')); + $this->assertSame('', $this->render('{{ include:chained }}{{ slot:header }}head{{ /slot:header }}{{ /include:chained }}')); + } + + public function test_default_slot_params_are_passed_as_props() + { + $this->viewShouldReturnRaw('scoped', '<{{ slot :label="title" }}>'); + $this->viewShouldReturnRaw('wrapper', '<{{ slot ensure_right="!" }}>'); + + $this->assertSame('<[T]>', $this->render('{{ include:scoped title="T" }}[{{ label }}]{{ /include:scoped }}')); + $this->assertSame('', $this->render('{{ include:wrapper }}body[{{ ensure_right }}]{{ /include:wrapper }}')); + } + + public function test_named_slot_params_are_always_passed_as_props() + { + $this->viewShouldReturnRaw('row', '{{ slot:row :label="title" ensure_right="!" }}'); + $this->viewShouldReturnRaw('card', '{{ slot:head :title="heading" }}'); + + $this->assertSame('[t|!]', $this->render('{{ include:row title="t" }}{{ slot:row }}[{{ label }}|{{ ensure_right }}]{{ /slot:row }}{{ /include:row }}')); + $this->assertSame('[H]', $this->render('{{ include:card heading="H" }}{{ slot:head }}[{{ title }}]{{ /slot:head }}{{ /include:card }}')); + } + public function test_scoped_slot_exposes_multiple_props() { $this->viewShouldReturnRaw('row', '{{ slot:row :label="title" :n="num" }}'); From 7fd66b1594d2214ab5ced7bae71534c3d97070a9 Mon Sep 17 00:00:00 2001 From: John Koster Date: Fri, 14 Aug 2026 22:28:50 -0500 Subject: [PATCH 07/12] Render Blade slot pair bodies as fallback content Review feedback pointed out that the compiled slot output discarded a pair's inner content, so a paired slot tag in an included Blade view rendered nothing when the slot was not supplied, and there was no supported way to provide a default when the slot alias collides with a data key. The question was whether pair bodies are fallbacks or are intentionally ignored. They are fallbacks. The compiled output now renders the provided slot when there is one and the pair body when there is not, matching what Blade users would expect from components. The Antlers side keeps its existing semantics (a missing named slot pair renders nothing, and conditions provide defaults), which is now pinned as well. Compiler methods are widened to protected alongside this for consistency with the rest of the trait. --- src/View/Blade/Concerns/CompilesPartials.php | 18 +++++++----- src/View/Blade/StatamicTagCompiler.php | 2 +- tests/Antlers/Runtime/Includes/SlotsTest.php | 7 +++++ .../AntlersComponents/IncludeCompilerTest.php | 28 +++++++++++++++++++ 4 files changed, 47 insertions(+), 8 deletions(-) diff --git a/src/View/Blade/Concerns/CompilesPartials.php b/src/View/Blade/Concerns/CompilesPartials.php index 99693bd653e..91e46a2a238 100644 --- a/src/View/Blade/Concerns/CompilesPartials.php +++ b/src/View/Blade/Concerns/CompilesPartials.php @@ -19,7 +19,7 @@ protected function isSlotTag(string $tagName): bool return $tagName === 'slot' || str($tagName)->startsWith(['slot.', 'slot:']); } - private function compileSlotOutput(ComponentNode $component): string + protected function compileSlotOutput(ComponentNode $component): string { if (! $this->isValidSlotName($name = $this->rawSlotName($component))) { return $this->compileComponent($component); @@ -32,7 +32,11 @@ private function compileSlotOutput(ComponentNode $component): string $context = '$'.IncludeTag::CONTEXT_KEY.' ?? false'; $output = '\Statamic\View\Slot::output('.$slot.', '.$this->compileParameters($component->parameters).')'; - return ''.$this->compileComponent($component).''; + $fallback = $this->isPairedComponent($component) + ? $this->compile($component->innerDocumentContent) + : ''; + + return ''.$fallback.''.$this->compileComponent($component).''; } protected function isComponentSlot(ComponentNode $parent, ComponentNode $child): bool @@ -70,7 +74,7 @@ protected function compileSlot(ComponentNode $node): array return [$name, $compiled]; } - private function compileIncludeSlot(ComponentNode $node): array + protected function compileIncludeSlot(ComponentNode $node): array { $name = $this->rawSlotName($node); @@ -81,14 +85,14 @@ private function compileIncludeSlot(ComponentNode $node): array return [$name, $this->compile($node->innerDocumentContent)]; } - private function rawSlotName(ComponentNode $component): string + protected function rawSlotName(ComponentNode $component): string { $name = (string) str($component->name)->substr(5); return $name === '' ? 'slot' : $name; } - private function isValidSlotName(string $name): bool + protected function isValidSlotName(string $name): bool { return (bool) preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $name); } @@ -98,12 +102,12 @@ protected function compilePartial(ComponentNode $component): string return $this->compileViewTag($component, isInclude: false); } - private function compileInclude(ComponentNode $component): string + protected function compileInclude(ComponentNode $component): string { return $this->compileViewTag($component, isInclude: true); } - private function compileViewTag(ComponentNode $component, bool $isInclude): string + protected function compileViewTag(ComponentNode $component, bool $isInclude): string { [$slots, $newContent] = $this->extractSlots($component); $params = $component->getParameters()->keyBy(fn (ParameterNode $param) => $param->materializedName); diff --git a/src/View/Blade/StatamicTagCompiler.php b/src/View/Blade/StatamicTagCompiler.php index 651cfabaa19..5efd1d3576f 100644 --- a/src/View/Blade/StatamicTagCompiler.php +++ b/src/View/Blade/StatamicTagCompiler.php @@ -127,7 +127,7 @@ protected function isPartial(ComponentNode $component): bool return $component->tagName == 'partial' || str($component->tagName)->lower()->startsWith('partial:'); } - private function isInclude(ComponentNode $component): bool + protected function isInclude(ComponentNode $component): bool { return $component->tagName == 'include' || str($component->tagName)->lower()->startsWith('include:'); } diff --git a/tests/Antlers/Runtime/Includes/SlotsTest.php b/tests/Antlers/Runtime/Includes/SlotsTest.php index fb643d1e049..ea465f3782e 100644 --- a/tests/Antlers/Runtime/Includes/SlotsTest.php +++ b/tests/Antlers/Runtime/Includes/SlotsTest.php @@ -152,6 +152,13 @@ public function test_named_slot_presence_is_false_when_empty() $this->assertSame('NO', $this->render('{{ include:card }}{{ slot:header }} {{ /slot:header }}{{ /include:card }}')); } + public function test_a_missing_named_slot_used_as_a_pair_renders_nothing() + { + $this->viewShouldReturnRaw('card', '{{ slot:header }}fallback{{ /slot:header }}'); + + $this->assertSame('', $this->render('{{ include:card }}body{{ /include:card }}')); + } + public function test_pipe_modifiers_can_be_applied_to_slots() { $this->viewShouldReturnRaw('wrapper', '<{{ slot | upper }}>'); diff --git a/tests/View/Blade/AntlersComponents/IncludeCompilerTest.php b/tests/View/Blade/AntlersComponents/IncludeCompilerTest.php index cd5014a178f..b05946525cb 100644 --- a/tests/View/Blade/AntlersComponents/IncludeCompilerTest.php +++ b/tests/View/Blade/AntlersComponents/IncludeCompilerTest.php @@ -131,6 +131,34 @@ public function a_named_slot_can_be_output_with_the_slot_tag() $this->assertSame('
', Blade::render('')); } + #[Test] + public function a_paired_named_slot_falls_back_to_its_body_when_the_slot_is_not_provided() + { + $this->viewShouldReturnRaw('card', '
Default footer
', 'blade.php'); + + $this->assertSame('
Default footer
', Blade::render('')); + } + + #[Test] + public function a_paired_named_slot_renders_the_provided_slot_instead_of_its_body() + { + $this->viewShouldReturnRaw('card', '
Default footer
', 'blade.php'); + + $this->assertSame( + '
Provided
', + Blade::render('Provided') + ); + } + + #[Test] + public function a_paired_default_slot_falls_back_to_its_body_when_the_slot_is_not_provided() + { + $this->viewShouldReturnRaw('card', '
Default body
', 'blade.php'); + + $this->assertSame('
Default body
', Blade::render('')); + $this->assertSame('
Provided
', Blade::render('Provided')); + } + #[Test] public function it_forwards_exists_method_calls() { From 3f0f69b47f7b7d22d665a27965d07ffa973eb0a1 Mon Sep 17 00:00:00 2001 From: John Koster Date: Fri, 14 Aug 2026 22:29:10 -0500 Subject: [PATCH 08/12] Revive slots across static cache boundaries Review feedback flagged that serializing a slot baked in an eager, props-less render: a scoped slot inside a nocache region served stale content on cached requests and degraded with no signal. Slots now serialize as their original source instead of freezing. An Antlers slot stores its raw template text (already available on paired nodes as runtimeContent) along with the caller state it needs, a Blade slot stores its hoisted template, and both carry their captured scope, filtered through the same rules nocache regions already use. On the way back out the slot re-parses through the bound parser, which also restores cascade access, runtime configuration, and variable guards on replay. Storing source rather than the node graph keeps payloads to a couple hundred bytes; serialized nodes dragged the entire document in through parser back-references. Supporting changes, each pinned by the new nocache and slot tests: - Region grows a preserved-context-keys mechanism (registered by the view provider, no include knowledge in Region itself) so the include carrier keys survive region filtering; without them, compiled Blade slot output loses its context on replay and falls through to a TagNotFoundException. The same filtering is reused for slot scopes, which also keeps slot forwarding working across replays. - Named slot references resolve by instance as well as by the per-request indicator, since the indicator can never match in a replayed region. - A scope that genuinely cannot be serialized throws at cache time, naming the slot and the offending keys, rather than quietly caching wrong output. Deferred Blade component variables are resolved the same way regions already resolve them. A template matching a view name is prefixed so Blade::render treats it as content. - Closure-built slots keep the eager render, since there is no other representation, and reject late props loudly. --- src/Providers/ViewServiceProvider.php | 4 + src/StaticCaching/NoCache/Region.php | 28 +-- .../Runtime/Concerns/ManagesIncludeSlots.php | 30 ++- .../Language/Runtime/PathDataManager.php | 12 +- src/View/Blade/Concerns/CompilesPartials.php | 2 +- src/View/Slot.php | 169 +++++++++++++-- tests/StaticCaching/IncludeNocacheTest.php | 196 ++++++++++++++++++ tests/View/SlotTest.php | 111 ++++++++++ 8 files changed, 508 insertions(+), 44 deletions(-) create mode 100644 tests/StaticCaching/IncludeNocacheTest.php create mode 100644 tests/View/SlotTest.php diff --git a/src/Providers/ViewServiceProvider.php b/src/Providers/ViewServiceProvider.php index a5661315580..2ebcba95062 100644 --- a/src/Providers/ViewServiceProvider.php +++ b/src/Providers/ViewServiceProvider.php @@ -10,6 +10,8 @@ use Statamic\Contracts\View\Antlers\Parser as ParserContract; use Statamic\Facades\Site; use Statamic\Statamic; +use Statamic\StaticCaching\NoCache\Region; +use Statamic\Tags\IncludeTag; use Statamic\View\Antlers\Engine; use Statamic\View\Antlers\Language\Analyzers\NodeTypeAnalyzer; use Statamic\View\Antlers\Language\Runtime\Debugging\GlobalDebugManager; @@ -425,6 +427,8 @@ public function boot() { ViewFactory::addNamespace('compiled__views', storage_path('framework/views')); + Region::preserveContextKeys(IncludeTag::VIEW_DATA_KEYS); + $this->registerBladeDirectives(); Blade::precompiler(function ($content) { diff --git a/src/StaticCaching/NoCache/Region.php b/src/StaticCaching/NoCache/Region.php index 257004d461d..b086fdb95ba 100644 --- a/src/StaticCaching/NoCache/Region.php +++ b/src/StaticCaching/NoCache/Region.php @@ -11,6 +11,13 @@ abstract class Region protected $context = []; protected $session; + protected static $preservedContextKeys = []; + + public static function preserveContextKeys(array $keys): void + { + static::$preservedContextKeys = array_unique(array_merge(static::$preservedContextKeys, $keys)); + } + public function setSession(Session $session) { $this->session = $session; @@ -26,21 +33,18 @@ public function context(): array return $this->context; } - protected function filterContext(array $context) + public static function filterCacheable(array $context): array { - $context = collect($context) - ->reject(fn ($value, $key) => str_starts_with((string) $key, '__')) - ->reject(fn ($value, $key) => in_array($key, ['app', 'errors', 'resolve', 'resolveComponentsUsing', 'forgetComponentsResolver', 'forgetFactory', 'flushCache', 'constructor'])) - ->map(function ($value, $key) { - if ($value instanceof InvokableComponentVariable) { - return $value->resolveDisplayableValue(); - } - - return $value; - }) + return collect($context) + ->reject(fn ($value, $key) => str_starts_with((string) $key, '__') && ! in_array($key, static::$preservedContextKeys)) + ->reject(fn ($value, $key) => in_array($key, ['app', 'errors', 'obLevel', 'resolve', 'resolveComponentsUsing', 'forgetComponentsResolver', 'forgetFactory', 'flushCache', 'constructor'])) + ->map(fn ($value) => $value instanceof InvokableComponentVariable ? $value->resolveDisplayableValue() : $value) ->all(); + } - return $this->arrayRecursiveDiff($context, $this->session->cascade()); + protected function filterContext(array $context) + { + return $this->arrayRecursiveDiff(static::filterCacheable($context), $this->session->cascade()); } public function fragmentData(): array diff --git a/src/View/Antlers/Language/Runtime/Concerns/ManagesIncludeSlots.php b/src/View/Antlers/Language/Runtime/Concerns/ManagesIncludeSlots.php index 3e91115f9d4..8ccc814f1ea 100644 --- a/src/View/Antlers/Language/Runtime/Concerns/ManagesIncludeSlots.php +++ b/src/View/Antlers/Language/Runtime/Concerns/ManagesIncludeSlots.php @@ -5,7 +5,6 @@ use Statamic\Tags\IncludeTag; use Statamic\View\Antlers\Language\Nodes\AntlersNode; use Statamic\View\Antlers\Language\Nodes\LiteralNode; -use Statamic\View\Antlers\Language\Runtime\GlobalRuntimeState; use Statamic\View\Slot; trait ManagesIncludeSlots @@ -41,35 +40,34 @@ protected function buildIncludeSlots(AntlersNode $node, array $callerData): arra $slots = []; if ($this->slotHasContent($defaultChildren)) { - $slots['slot'] = $this->makeSlot($defaultChildren, $callerData); + $slots['slot'] = Slot::forAntlers($defaultChildren, $this->defaultSlotSource($node, $namedSlots), $callerData, $this); } foreach ($namedSlots as $slotName => $slotNode) { if ($this->slotHasContent($slotNode->children)) { - $slots[$slotName] = $this->makeSlot($slotNode->children, $callerData); + $slots[$slotName] = Slot::forAntlers($slotNode->children, $slotNode->runtimeContent, $callerData, $this); } } return $slots; } - protected function makeSlot(array $nodes, array $callerData): Slot + protected function defaultSlotSource(AntlersNode $node, array $namedSlots): string { - $callerState = [GlobalRuntimeState::$isCascadeEnabled, GlobalRuntimeState::$prefixState]; - - $renderer = function (array $data) use ($nodes, $callerState) { - $tagState = [GlobalRuntimeState::$isCascadeEnabled, GlobalRuntimeState::$prefixState]; + if (empty($namedSlots)) { + return $node->runtimeContent; + } - [GlobalRuntimeState::$isCascadeEnabled, GlobalRuntimeState::$prefixState] = $callerState; + $parser = $node->getParser(); + $start = $node->endPosition->index + 1; + $source = ''; - try { - return $this->cloneProcessor()->setData($data)->reduce($nodes); - } finally { - [GlobalRuntimeState::$isCascadeEnabled, GlobalRuntimeState::$prefixState] = $tagState; - } - }; + foreach ($namedSlots as $slotNode) { + $source .= $parser->getText($start, $slotNode->startPosition->index); + $start = ($slotNode->isClosedBy ?? $slotNode)->endPosition->index + 1; + } - return new Slot($renderer, $callerData); + return $source.$parser->getText($start, $node->isClosedBy->startPosition->index); } protected function isNamedSlotNode($node): bool diff --git a/src/View/Antlers/Language/Runtime/PathDataManager.php b/src/View/Antlers/Language/Runtime/PathDataManager.php index 24831b17be8..db5c5f9c048 100644 --- a/src/View/Antlers/Language/Runtime/PathDataManager.php +++ b/src/View/Antlers/Language/Runtime/PathDataManager.php @@ -609,9 +609,7 @@ public function getData(VariableReference $path, $data, $isForArrayIndex = false } if ($didScanSourceData == false) { - if ($this->namedSlotsInScope && $pathItem->name == 'slot' && - $path->originalContent != 'slot' && - array_key_exists($path->originalContent, $data)) { + if ($this->isNamedSlotReference($pathItem, $path, $data)) { $this->reducedVar = $data[$path->originalContent]; break; } @@ -796,6 +794,14 @@ public function getData(VariableReference $path, $data, $isForArrayIndex = false return $this->reducedVar; } + private function isNamedSlotReference(PathNode $pathItem, $path, $data): bool + { + return $pathItem->name == 'slot' && + $path->originalContent != 'slot' && + array_key_exists($path->originalContent, $data) && + ($this->namedSlotsInScope || $data[$path->originalContent] instanceof Slot); + } + /** * Sets the parser instance to use when reducing content values. * diff --git a/src/View/Blade/Concerns/CompilesPartials.php b/src/View/Blade/Concerns/CompilesPartials.php index 91e46a2a238..8d81bbfa33b 100644 --- a/src/View/Blade/Concerns/CompilesPartials.php +++ b/src/View/Blade/Concerns/CompilesPartials.php @@ -158,7 +158,7 @@ protected function compileViewTag(ComponentNode $component, bool $isInclude): st $injectedParam->type = ParameterType::DynamicVariable; $injectedParam->value = $isInclude - ? 'new \Statamic\View\Slot(fn ($__slotData) => \Illuminate\Support\Facades\Blade::render($'.$hoistedVarName.', $__slotData), get_defined_vars())' + ? '\Statamic\View\Slot::forBlade($'.$hoistedVarName.', get_defined_vars())' : 'new \Illuminate\Support\HtmlString(\Illuminate\Support\Facades\Blade::render($'.$hoistedVarName.', get_defined_vars()))'; $hoistedSet .= Str::swap([ diff --git a/src/View/Slot.php b/src/View/Slot.php index 6f0a7e8ac1c..807610ad9be 100644 --- a/src/View/Slot.php +++ b/src/View/Slot.php @@ -4,11 +4,28 @@ use Closure; use Illuminate\Contracts\Support\Htmlable; +use Illuminate\Support\Facades\Blade; +use RuntimeException; +use Statamic\Contracts\View\Antlers\Parser as ParserContract; +use Statamic\StaticCaching\NoCache\Region; +use Statamic\View\Antlers\Language\Runtime\GlobalRuntimeState; +use Statamic\View\Antlers\Language\Runtime\NodeProcessor; +use Throwable; class Slot implements Htmlable { protected array $params = []; + protected bool $static = false; + + protected ?string $name = null; + + protected ?string $source = null; + + protected ?array $callerState = null; + + protected ?string $template = null; + /** * @param Closure(array): mixed $renderer Renders the slot's contents using the supplied data. * @param array $data The scope the slot was defined in. @@ -19,8 +36,71 @@ public function __construct( ) { } + public static function forAntlers(array $nodes, string $source, array $data, NodeProcessor $processor): static + { + $state = static::captureCallerState(); + + $slot = new static(static::antlersRenderer($nodes, $state, $processor), $data); + + $slot->source = $source; + $slot->callerState = $state; + + return $slot; + } + + public static function forBlade(string $template, array $data): static + { + // Prevents Blade::render from mistaking short slot content for a view name. + $template = '{{-- slot --}}'.$template; + + $slot = new static(fn (array $slotData) => Blade::render($template, $slotData), $data); + + $slot->template = $template; + + return $slot; + } + + protected static function captureCallerState(): array + { + return [GlobalRuntimeState::$isCascadeEnabled, GlobalRuntimeState::$prefixState]; + } + + protected static function antlersRenderer(array $nodes, array $callerState, NodeProcessor $processor): Closure + { + return function (array $data) use ($nodes, $callerState, $processor) { + $tagState = static::captureCallerState(); + + [GlobalRuntimeState::$isCascadeEnabled, GlobalRuntimeState::$prefixState] = $callerState; + + try { + return $processor->cloneProcessor()->setData($data)->reduce($nodes); + } finally { + [GlobalRuntimeState::$isCascadeEnabled, GlobalRuntimeState::$prefixState] = $tagState; + } + }; + } + + protected static function textRenderer(string $source, array $callerState): Closure + { + return function (array $data) use ($source, $callerState) { + $tagState = static::captureCallerState(); + + [GlobalRuntimeState::$isCascadeEnabled, GlobalRuntimeState::$prefixState] = $callerState; + + try { + return (string) app(ParserContract::class)->parse($source, $data); + } finally { + [GlobalRuntimeState::$isCascadeEnabled, GlobalRuntimeState::$prefixState] = $tagState; + } + }; + } + public function render(array $props = []): string { + if ($this->static && $props !== []) { + throw new RuntimeException("The {$this->displayName()} has already been rendered and cached, so data can no longer be passed to it. This usually happens when a scoped slot is rendered inside a nocache region."); + } + $data = array_merge($this->data, ['params' => $this->params], $props); return trim((string) ($this->renderer)($data)); @@ -33,23 +113,16 @@ public function withParams(array $params): static return $this; } - public function toHtml(): string + public function named(?string $name): static { - return $this->render(); - } + $this->name = $name; - public function __serialize(): array - { - return ['rendered' => $this->render()]; + return $this; } - public function __unserialize(array $data): void + public function toHtml(): string { - $rendered = $data['rendered'] ?? ''; - - $this->renderer = fn () => $rendered; - $this->data = []; - $this->params = []; + return $this->render(); } public function __toString(): string @@ -65,4 +138,76 @@ public static function output(mixed $slot, array $props = []): string return e($slot); } + + public function __serialize(): array + { + if ($this->source === null && $this->template === null) { + return ['name' => $this->name, 'rendered' => $this->render()]; + } + + try { + return array_filter([ + 'name' => $this->name, + 'antlers' => $this->source, + 'state' => $this->callerState, + 'template' => $this->template, + 'data' => serialize(Region::filterCacheable($this->data)), + 'params' => serialize(Region::filterCacheable($this->params)), + ], fn ($value) => $value !== null); + } catch (Throwable $e) { + throw new RuntimeException("The {$this->displayName()} cannot be cached because its scope contains values that cannot be serialized{$this->unserializableKeys()}.", previous: $e); + } + } + + public function __unserialize(array $data): void + { + $this->name = $data['name'] ?? null; + + if (array_key_exists('rendered', $data)) { + $rendered = $data['rendered']; + + $this->data = []; + $this->params = []; + $this->renderer = fn () => $rendered; + $this->static = true; + + return; + } + + $this->data = unserialize($data['data']); + $this->params = unserialize($data['params']); + + if (isset($data['template'])) { + $this->template = $data['template']; + $this->renderer = fn (array $slotData) => Blade::render($this->template, $slotData); + + return; + } + + $this->source = $data['antlers']; + $this->callerState = $data['state']; + $this->renderer = static::textRenderer($this->source, $this->callerState); + } + + protected function unserializableKeys(): string + { + $keys = collect(Region::filterCacheable(array_merge($this->data, $this->params))) + ->filter(function ($value) { + try { + serialize($value); + + return false; + } catch (Throwable) { + return true; + } + }) + ->keys(); + + return $keys->isEmpty() ? '' : ' ('.$keys->implode(', ').')'; + } + + protected function displayName(): string + { + return $this->name ? "[{$this->name}] slot" : 'slot'; + } } diff --git a/tests/StaticCaching/IncludeNocacheTest.php b/tests/StaticCaching/IncludeNocacheTest.php new file mode 100644 index 00000000000..be1919b288a --- /dev/null +++ b/tests/StaticCaching/IncludeNocacheTest.php @@ -0,0 +1,196 @@ +set('cache.default', 'file'); + $app['config']->set('statamic.static_caching.strategy', 'half'); + $app['config']->set('statamic.antlers.guardedVariables', ['secret_thing']); + } + + private function setUpViews(string $layout, array $views) + { + $this->withFakeViews(); + $this->viewShouldReturnRaw('layout', '{{ template_content }}'); + $this->viewShouldReturnRaw('default', $layout); + + foreach ($views as $name => $contents) { + $this->viewShouldReturnRaw($name, $contents); + } + + return $this->createPage('about', ['with' => ['title' => 'Existing']]); + } + + private function setUpBladeViews(array $views) + { + foreach ($views as $name => $contents) { + $this->viewShouldReturnRaw($name, $contents, 'blade.php'); + } + + view()->addNamespace('compiled__views', storage_path('framework/views')); + } + + private function renderTwice(string $layout, array $views): array + { + $page = $this->setUpViews($layout, $views); + + $first = $this->get('/about')->assertOk()->content(); + + $page->set('title', 'Updated')->saveQuietly(); + + $second = $this->get('/about')->assertOk()->content(); + + return [trim($first), trim($second)]; + } + + #[Test] + public function a_nocache_region_inside_an_included_view_stays_dynamic() + { + $this->assertSame( + ['W[Existing]', 'W[Updated]'], + $this->renderTwice('{{ include:w }}', ['w' => 'W{{ nocache }}[{{ title }}]{{ /nocache }}']) + ); + } + + #[Test] + public function a_nocache_region_around_an_include_stays_dynamic() + { + $this->assertSame( + ['W[Existing]', 'W[Updated]'], + $this->renderTwice('{{ nocache }}{{ include:w :t="title" }}{{ /nocache }}', ['w' => 'W[{{ t }}]']) + ); + } + + #[Test] + public function a_nocache_region_inside_slot_contents_stays_dynamic() + { + $this->assertSame( + ['W[Existing]', 'W[Updated]'], + $this->renderTwice( + '{{ include:w }}{{ nocache }}[{{ title }}]{{ /nocache }}{{ /include:w }}', + ['w' => 'W{{ slot }}'] + ) + ); + } + + #[Test] + public function a_slot_inside_a_nocache_region_renders_with_its_captured_scope() + { + $this->assertSame( + ['W[Existing]', 'W[Existing]'], + $this->renderTwice( + '{{ include:w }}[{{ title }}]{{ /include:w }}', + ['w' => 'W{{ nocache }}{{ slot }}{{ /nocache }}'] + ) + ); + } + + #[Test] + public function a_named_slot_inside_an_antlers_nocache_region_stays_dynamic() + { + $page = $this->setUpViews( + '{{ include:w :title="title" }}{{ slot:row }}{{ if label }}[{{ label | upper }}]{{ /if }}{{ /slot:row }}{{ /include:w }}', + ['w' => '{{ nocache }}{{ slot:row :label="title" }}{{ /nocache }}'] + ); + + $this->assertSame('[EXISTING]', trim($this->get('/about')->assertOk()->content())); + + $page->set('title', 'Updated')->saveQuietly(); + + $this->assertSame('[UPDATED]', trim($this->get('/about')->assertOk()->content())); + } + + #[Test] + public function a_scoped_slot_inside_a_nocache_region_stays_dynamic() + { + $page = $this->setUpViews( + '{{ include:w :title="title" }}{{ slot:row }}{{ if label }}[{{ label | upper }}]{{ /if }}{{ /slot:row }}{{ /include:w }}', + [] + ); + $this->setUpBladeViews(['w' => '']); + + $this->assertSame('[EXISTING]', trim($this->get('/about')->assertOk()->content())); + + $page->set('title', 'Updated')->saveQuietly(); + + $this->assertSame('[UPDATED]', trim($this->get('/about')->assertOk()->content())); + } + + #[Test] + public function escaped_antlers_inside_a_revived_slot_stays_escaped() + { + $this->assertSame( + ['A{{ x }}B', 'A{{ x }}B'], + $this->renderTwice( + '{{ include:w }}{{ slot:row }}A@{{ x }}B{{ /slot:row }}{{ /include:w }}', + ['w' => '{{ nocache }}{{ slot:row }}{{ /nocache }}'] + ) + ); + } + + #[Test] + public function a_tag_inside_a_revived_slot_can_parse_its_content() + { + $this->assertSame( + ['A[Existing]B', 'A[Existing]B'], + $this->renderTwice( + '{{ include:w }}{{ slot:row }}A{{ cache }}[{{ title }}]{{ /cache }}B{{ /slot:row }}{{ /include:w }}', + ['w' => '{{ nocache }}{{ slot:row }}{{ /nocache }}'] + ) + ); + } + + #[Test] + public function a_revived_slot_keeps_runtime_variable_guards() + { + $page = $this->setUpViews( + '{{ include:w }}{{ slot:row }}X{{ secret_thing }}Y{{ /slot:row }}{{ /include:w }}', + [] + ); + $page->set('secret_thing', 'S3CRET')->saveQuietly(); + $this->setUpBladeViews(['w' => '']); + + $this->assertSame('XY', trim($this->get('/about')->assertOk()->content())); + + // Simulate a fresh process, where nothing has resolved the parser binding yet. + GlobalRuntimeState::$bannedVarPaths = []; + GlobalRuntimeState::$bannedContentVarPaths = []; + + $this->assertSame('XY', trim($this->get('/about')->assertOk()->content())); + } + + #[Test] + public function a_forwarded_slot_inside_a_nocache_region_survives_replay() + { + $page = $this->setUpViews( + '{{ include:middle }}{{ slot:leaf }}[{{ title }}]{{ /slot:leaf }}{{ /include:middle }}', + [] + ); + $this->setUpBladeViews([ + 'middle' => '', + 'inner' => '', + ]); + + $this->assertSame('[Existing]', trim($this->get('/about')->assertOk()->content())); + + $page->set('title', 'Updated')->saveQuietly(); + + $this->assertSame('[Existing]', trim($this->get('/about')->assertOk()->content())); + } +} diff --git a/tests/View/SlotTest.php b/tests/View/SlotTest.php new file mode 100644 index 00000000000..677b9508708 --- /dev/null +++ b/tests/View/SlotTest.php @@ -0,0 +1,111 @@ + implode(',', array_filter([ + $data['outer'] ?? null, + $data['params']['title'] ?? null, + $data['n'] ?? null, + ])), ['outer' => 'O']); + + $slot->withParams(['title' => 'T']); + + $this->assertSame('O,T', $slot->render()); + $this->assertSame('O,T,3', $slot->render(['n' => '3'])); + } + + public function test_a_closure_slot_serializes_as_rendered_content() + { + $slot = new Slot(fn ($data) => 'rendered:'.($data['outer'] ?? ''), ['outer' => 'O']); + + $revived = unserialize(serialize($slot)); + + $this->assertSame('rendered:O', $revived->render()); + $this->assertSame('rendered:O', $revived->toHtml()); + } + + public function test_a_revived_closure_slot_rejects_props() + { + $slot = (new Slot(fn ($data) => 'x', []))->named('row'); + + $revived = unserialize(serialize($slot)); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('The [row] slot has already been rendered and cached'); + + $revived->render(['n' => '3']); + } + + public function test_a_revived_blade_slot_renders_with_its_data_params_and_props() + { + $slot = Slot::forBlade('{{ $outer ?? "?" }},{{ $params["title"] ?? "?" }},{{ $n ?? "?" }}', ['outer' => 'O']) + ->withParams(['title' => 'T']); + + $revived = unserialize(serialize($slot)); + + $this->assertSame('O,T,?', $revived->render()); + $this->assertSame('O,T,3', $revived->render(['n' => '3'])); + } + + public function test_nested_slots_survive_serialization() + { + $inner = Slot::forBlade('inner:{{ $x ?? "?" }}', ['x' => 'X']); + $outer = Slot::forBlade('outer({{ $nested }})', ['nested' => $inner]); + + $revived = unserialize(serialize($outer)); + + $this->assertSame('outer(inner:X)', $revived->render()); + } + + public function test_a_revived_slot_survives_another_serialization_round_trip() + { + $slot = Slot::forBlade('{{ $outer }},{{ $n ?? "?" }}', ['outer' => 'O']); + + $twice = unserialize(serialize(unserialize(serialize($slot)))); + + $this->assertSame('O,3', $twice->render(['n' => '3'])); + } + + public function test_component_scope_variables_are_resolved_when_cached() + { + $slot = Slot::forBlade('{{ $label }}', ['label' => new InvokableComponentVariable(fn () => 'V')]); + + $revived = unserialize(serialize($slot)); + + $this->assertSame('V', $revived->render()); + } + + public function test_slot_content_matching_a_view_name_renders_as_content() + { + $this->withFakeViews(); + $this->viewShouldReturnRaw('footer', 'THE VIEW', 'blade.php'); + + $slot = Slot::forBlade('footer', []); + + $this->assertSame('footer', $slot->render()); + $this->assertSame('footer', unserialize(serialize($slot))->render()); + } + + public function test_a_slot_with_unserializable_scope_throws_when_cached() + { + $slot = Slot::forBlade('{{ $outer ?? "?" }}', ['outer' => 'O', 'bad' => fn () => null]) + ->named('row'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('The [row] slot cannot be cached because its scope contains values that cannot be serialized (bad)'); + + serialize($slot); + } +} From 08bebcdf2ef726cb2a01af9d9c1a49390b1a5150 Mon Sep 17 00:00:00 2001 From: John Koster Date: Fri, 14 Aug 2026 22:29:21 -0500 Subject: [PATCH 09/12] Clone slots before applying view params Naming a slot and attaching the view's params mutated the Slot instance in place. Forwarding the same instance into a nested include then clobbered the outer view's slot: the inner include's params replaced the outer ones, so rendering the slot again after the nested include showed the wrong values. Each include now works with its own clone, so every view names and parameterizes its slots without affecting anyone else holding the same slot. This came out of reviewing the slot lifecycle for the serialization feedback. --- src/Tags/IncludeTag.php | 2 +- tests/Antlers/Runtime/Includes/SlotsTest.php | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/Tags/IncludeTag.php b/src/Tags/IncludeTag.php index ba628221b2e..b7008f32552 100644 --- a/src/Tags/IncludeTag.php +++ b/src/Tags/IncludeTag.php @@ -167,7 +167,7 @@ protected function resolveSlots(array $parameters, array $data, bool $isBlade): foreach ($slots as $name => $slot) { if ($slot instanceof Slot) { - $slot->withParams($data); + $slot = (clone $slot)->withParams($data)->named($name === 'slot' ? null : $name); } if ($name === 'slot') { diff --git a/tests/Antlers/Runtime/Includes/SlotsTest.php b/tests/Antlers/Runtime/Includes/SlotsTest.php index ea465f3782e..07c1e070ce8 100644 --- a/tests/Antlers/Runtime/Includes/SlotsTest.php +++ b/tests/Antlers/Runtime/Includes/SlotsTest.php @@ -159,6 +159,17 @@ public function test_a_missing_named_slot_used_as_a_pair_renders_nothing() $this->assertSame('', $this->render('{{ include:card }}body{{ /include:card }}')); } + public function test_forwarding_a_slot_to_a_nested_include_does_not_clobber_the_outer_one() + { + $this->viewShouldReturnRaw('outer', '{{ include:inner tag="I" :__statamic_include_slot_body="slot:body" }}|{{ slot:body }}'); + $this->viewShouldReturnRaw('inner', '{{ slot:body }}'); + + $this->assertSame( + '[I]|[O]', + $this->render('{{ include:outer tag="O" }}{{ slot:body }}[{{ params:tag }}]{{ /slot:body }}{{ /include:outer }}') + ); + } + public function test_pipe_modifiers_can_be_applied_to_slots() { $this->viewShouldReturnRaw('wrapper', '<{{ slot | upper }}>'); From 12483aa935e35b6e3457d78742a9b55fbce064fb Mon Sep 17 00:00:00 2001 From: John Koster Date: Fri, 14 Aug 2026 22:29:30 -0500 Subject: [PATCH 10/12] Pin existence, src, and index view coverage Adds coverage that came out of review discussion rather than code changes: the exists and if_exists forms work through the include tag, the src parameter form compiles on the Blade side, and a view literally named index stays reachable from both engines. The bare tag resolving to the index view intentionally matches what partial does, since a site can genuinely have an index view. --- .../Antlers/Runtime/Includes/IncludeTagTest.php | 16 ++++++++++++++++ .../AntlersComponents/IncludeCompilerTest.php | 17 +++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/tests/Antlers/Runtime/Includes/IncludeTagTest.php b/tests/Antlers/Runtime/Includes/IncludeTagTest.php index bc8e0750451..5b6f1f4d41c 100644 --- a/tests/Antlers/Runtime/Includes/IncludeTagTest.php +++ b/tests/Antlers/Runtime/Includes/IncludeTagTest.php @@ -230,4 +230,20 @@ public function test_unless_param_controls_rendering() $this->assertSame('', $this->render('{{ include:greeting unless="true" }}')); $this->assertSame('Hello', $this->render('{{ include:greeting unless="false" }}')); } + + public function test_include_if_exists_renders_the_view_when_it_exists() + { + $this->viewShouldReturnRaw('cards.author', 'Author'); + + $this->assertSame('Author', $this->render('{{ include:if_exists src="cards/author" }}')); + $this->assertSame('', $this->render('{{ include:if_exists src="nope" }}')); + } + + public function test_include_exists_in_a_condition() + { + $this->viewShouldReturnRaw('cards.author', 'Author'); + + $this->assertSame('yes', $this->render('{{ if {include:exists src="cards/author"} }}yes{{ else }}no{{ /if }}')); + $this->assertSame('no', $this->render('{{ if {include:exists src="nope"} }}yes{{ else }}no{{ /if }}')); + } } diff --git a/tests/View/Blade/AntlersComponents/IncludeCompilerTest.php b/tests/View/Blade/AntlersComponents/IncludeCompilerTest.php index b05946525cb..b0c07996b19 100644 --- a/tests/View/Blade/AntlersComponents/IncludeCompilerTest.php +++ b/tests/View/Blade/AntlersComponents/IncludeCompilerTest.php @@ -35,6 +35,23 @@ public function it_compiles_include_tags() $this->assertSame($expected, Blade::render('')); } + #[Test] + public function it_compiles_include_tags_with_a_src_parameter() + { + $this->viewShouldReturnRaw('alert', '
{{ $title }}
', 'blade.php'); + + $this->assertSame('
The Title
', Blade::render('')); + } + + #[Test] + public function it_compiles_a_view_named_index() + { + $this->viewShouldReturnRaw('index', 'IDX'); + + $this->assertSame('IDX', Blade::render('')); + $this->assertSame('IDX', Blade::render('')); + } + #[Test] public function it_does_not_capture_the_caller_scope() { From 7ea62ab35506b6be51383d87d25f0e01a950cca6 Mon Sep 17 00:00:00 2001 From: John Koster Date: Fri, 14 Aug 2026 22:43:50 -0500 Subject: [PATCH 11/12] Construct slots with self Appeases PHPStan's new static() rule; Slot isn't an extension point. --- src/View/Slot.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/View/Slot.php b/src/View/Slot.php index 807610ad9be..6f31ebc6c74 100644 --- a/src/View/Slot.php +++ b/src/View/Slot.php @@ -36,11 +36,11 @@ public function __construct( ) { } - public static function forAntlers(array $nodes, string $source, array $data, NodeProcessor $processor): static + public static function forAntlers(array $nodes, string $source, array $data, NodeProcessor $processor): self { $state = static::captureCallerState(); - $slot = new static(static::antlersRenderer($nodes, $state, $processor), $data); + $slot = new self(static::antlersRenderer($nodes, $state, $processor), $data); $slot->source = $source; $slot->callerState = $state; @@ -48,12 +48,12 @@ public static function forAntlers(array $nodes, string $source, array $data, Nod return $slot; } - public static function forBlade(string $template, array $data): static + public static function forBlade(string $template, array $data): self { // Prevents Blade::render from mistaking short slot content for a view name. $template = '{{-- slot --}}'.$template; - $slot = new static(fn (array $slotData) => Blade::render($template, $slotData), $data); + $slot = new self(fn (array $slotData) => Blade::render($template, $slotData), $data); $slot->template = $template; From 82068da11cf1150f69ba9864b1cf292bbc7a043a Mon Sep 17 00:00:00 2001 From: John Koster Date: Fri, 14 Aug 2026 23:11:22 -0500 Subject: [PATCH 12/12] Scope view data isolation to the include tag The parseView() data restore previously applied to every view render. Restoring unconditionally is arguably correct, since a view leaving data behind on the processor is the same mechanism behind the #8175 leaks, but it also touches long-shipped partial behavior in edge cases. The restore is now opt-in per render and only the include tag requests it, so everything outside include keeps its existing behavior. A note on the flag covers removing it once the underlying bug is fixed for everyone. Deferred slot renders leaned on the unconditional restore: a partial rendered inside deferred slot content could clobber the enclosing view's scope once the restore became scoped. Slot output now locks processor data around the render, the same idiom the tag invocation path already uses. --- src/Tags/IncludeTag.php | 2 ++ src/View/Antlers/Language/Runtime/GlobalRuntimeState.php | 5 +++++ src/View/Antlers/Language/Runtime/NodeProcessor.php | 9 +++++++++ src/View/Antlers/Language/Runtime/RuntimeParser.php | 8 ++++++-- 4 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/Tags/IncludeTag.php b/src/Tags/IncludeTag.php index b7008f32552..f5eeae912b2 100644 --- a/src/Tags/IncludeTag.php +++ b/src/Tags/IncludeTag.php @@ -105,12 +105,14 @@ protected function render($view) GlobalRuntimeState::$isCascadeEnabled = false; GlobalRuntimeState::$prefixState = []; + GlobalRuntimeState::$isolateViewData = true; try { return $view->with($scope) ->withoutExtractions() ->render(); } finally { + GlobalRuntimeState::$isolateViewData = false; GlobalRuntimeState::$isCascadeEnabled = $suspendedCascade; GlobalRuntimeState::$prefixState = $suspendedPrefixes; diff --git a/src/View/Antlers/Language/Runtime/GlobalRuntimeState.php b/src/View/Antlers/Language/Runtime/GlobalRuntimeState.php index 012abd2259f..b60ce9abb10 100644 --- a/src/View/Antlers/Language/Runtime/GlobalRuntimeState.php +++ b/src/View/Antlers/Language/Runtime/GlobalRuntimeState.php @@ -219,6 +219,11 @@ public static function mergeTagRuntimeAssignments($assignments) public static $requiresRuntimeIsolation = false; + // Scopes the parseView() data restore to renders that request it (the include tag). + // Views leaving data behind on the processor is arguably the real bug, but other + // renders keep that behavior for BC. Remove once that is fixed for everyone. + public static $isolateViewData = false; + public static $evaulatingTagContents = false; public static $userContentEvalState = null; diff --git a/src/View/Antlers/Language/Runtime/NodeProcessor.php b/src/View/Antlers/Language/Runtime/NodeProcessor.php index 17a7fa4bbd1..49d20d663a6 100644 --- a/src/View/Antlers/Language/Runtime/NodeProcessor.php +++ b/src/View/Antlers/Language/Runtime/NodeProcessor.php @@ -2078,6 +2078,12 @@ public function reduce($processNodes) } if ($this->guardRuntime($node, $runtimeResult)) { + if ($runtimeResult instanceof Slot) { + $lockData = $this->data; + $runtimeResult = $runtimeResult->render(); + $this->data = $lockData; + } + $buffer .= $this->measureBufferAppend($node, $this->modifyBufferAppend($runtimeResult)); } @@ -2162,7 +2168,10 @@ public function reduce($processNodes) } if ($val instanceof Slot) { + $lockData = $this->data; $val = $val->render($node->hasParameters ? $this->getSlotOutputProps($node) : []); + $this->data = $lockData; + $buffer .= $this->measureBufferAppend($node, $this->modifyBufferAppend($val)); if ($this->isTracingEnabled()) { diff --git a/src/View/Antlers/Language/Runtime/RuntimeParser.php b/src/View/Antlers/Language/Runtime/RuntimeParser.php index 39b9f9546e8..f0b31e57d55 100644 --- a/src/View/Antlers/Language/Runtime/RuntimeParser.php +++ b/src/View/Antlers/Language/Runtime/RuntimeParser.php @@ -771,12 +771,16 @@ public function parseView($view, $text, $data = []) $existingView = $this->view; - $suspendedData = $this->nodeProcessor->getAllData(); + $shouldSwapData = GlobalRuntimeState::$isolateViewData; + GlobalRuntimeState::$isolateViewData = false; + $suspendedData = $shouldSwapData ? $this->nodeProcessor->getAllData() : null; try { return $this->renderViewContent($view, $text, $data); } finally { - $this->nodeProcessor->swapData($suspendedData); + if ($shouldSwapData) { + $this->nodeProcessor->swapData($suspendedData); + } $this->view = $existingView; array_pop(GlobalRuntimeState::$templateFileStack); GlobalRuntimeState::$currentExecutionFile = $this->view;