Skip to content

fix(view)!: fix inconsistent fallthrough attributes and introduce apply attribute for full control#2092

Open
iamdadmin wants to merge 8 commits intotempestphp:3.xfrom
iamdadmin:3.x-applyFallthroughAttributes
Open

fix(view)!: fix inconsistent fallthrough attributes and introduce apply attribute for full control#2092
iamdadmin wants to merge 8 commits intotempestphp:3.xfrom
iamdadmin:3.x-applyFallthroughAttributes

Conversation

@iamdadmin
Copy link
Copy Markdown
Contributor

Closes #2040.

This is likely to be a breaking change, I've taken the liberty of writing a blog post in Markdown explaining it, which you're welcome to post, adapt, or not use as suits your needs.

If changes are needed, I can adjust as well. Here's it pasted in for all to read, I have attached it as a downloadable .md file as well for easy import to the website if you want it.

Updated-approach-for-fallthrough-attributes-in Tempest-View.md

Updated approach for fallthrough attributes in Tempest View

Tempest's view components provide a convenient automatic fallthrough of class, style, and id from the call site onto the root element of a component template. Pass class="mt-4" to <x-button /> and it appears on the <button> inside — no boilerplate needed.

However, a simple bug in the code meant that this didn't happen consistently, and even then there was no method to control the behaviour if it was not required. The inconsistent nature of the bug - anchored on whether or not there was a PHP preamble or other content before the 'first' HTML token in the component - means that a direct fix would be a breaking change for many applications.

The Bug

The original implementation used a single regular expression anchored to the start of the compiled template string:

$compiled->replaceRegex(
    regex: '/^<(?<tag>[\w-]+)(.*?["\s])?>/',
    ...
);

The ^ anchor means: match only if the very first character of the file is <. For a component like this, it worked fine:

<button class="rounded-md px-2.5 py-1.5 text-sm">
    <x-slot />
</button>

But the moment a PHP preamble appeared before the first tag — even a declare statement or a comment — the regex found nothing, silently produced no output, and the fallthrough was lost entirely:

<?php
$variant = $variant ?? 'primary';
?>
<button class="rounded-md px-2.5 py-1.5 text-sm">
    <x-slot />
</button>

No error. No warning. The class you passed from the parent simply never arrived.

Tempest version 3.8.0 brought changes to the way a view component was compiled, but the bug still asserted itself. The regex was removed and replaced with a proper AST walk using TempestViewParser::ast() - a big step forward - but the fallthrough check was anchored to the token index instead of the token type:

$shouldApplyFallthrough = $i === 0 && $token->type === TokenType::OPEN_TAG_START && $token->tag !== 'x-slot';

$i === 0 means: only attempt fallthrough if the first token in the parsed AST is the opening tag. However, a PHP preamble is its own token. So when one was present, the HTML element appeared at index 1 or later, $i === 0 was false, $shouldApplyFallthrough was false, and the fallthrough was silently dropped — exactly as before, just through different code. The same tests that failed before continued to fail locally.

Why wasn't it spotted before?

There are tests for this - several in fact. However, they all used minimal fixtures, and none of which had a PHP preamble.

<?php
$componentClass = 'component-class';
$componentStyle = 'display: block;';
?><x-fallthrough-test class="component-class" />
<x-fallthrough-test :class="$componentClass" />
<x-fallthrough-dynamic-test c="component-class" s="display: block;" />
<x-fallthrough-dynamic-test :c="$componentClass" :s="$componentStyle"/>
<div class="in-component"></div>
<div :class="$attributes->get('c')" :style="$attributes->get('s')"></div>

This update also brings updated tests, all of which include a PHP preamble, with simply a comment to hold it in the file, in order to ensure that the issue is tested for going forwards.

No developer control over fallthrough attributes

Beyond the bug, the original implementation was all-or-nothing. It always attempted to merge class, style, and id onto the first element, with no way to opt out, no way to target a specific element, and no way to know it had run. If your component defined its own class on the root element, the incoming value was appended on top of it regardless.

How it was resolved

Finding the first tag properly

The regex was replaced with a proper AST walk. TempestViewParser::ast() already tokenises the template, so instead of guessing with a pattern, we iterate tokens and find the first real OPEN_TAG_START or SELF_CLOSING_TAG — skipping past PHP blocks, whitespace, comments, and doctypes entirely:

foreach ($tokens as $token) {
    if (in_array($token->type, [TokenType::OPEN_TAG_START, TokenType::SELF_CLOSING_TAG], true)
        && $token->tag !== 'x-slot') {
        $firstToken = $token;
        break;
    }
}

The first valid HTML token is found regardless of what precedes it in the file. PHP preambles, declare(strict_types=1), comment blocks — none of them interfere.

Respecting what the component already declares

The previous behaviour unconditionally merged incoming values onto whatever the root element had. The new behaviour checks first: if the root element already declares class, :class, style, :style, id, or :id, the fallthrough for that attribute is now skipped entirely.

if (array_key_exists($name, $firstToken->htmlAttributes)
    || array_key_exists(':' . $name, $firstToken->htmlAttributes)) {
    continue;
}

For applications previously relying on the mandatory merge of these attributes, this is a breaking change, meaning you'll need to adjust your views to manually implement the merge. This was a necessary change however, in order to add control.

This now means a component can take ownership of any of these attributes simply by declaring them. Pass whatever you like from the call site — if the component has already configured it, the component wins:

<button :class="$class ?? 'rounded-md px-2.5 py-1.5 text-sm'">
    <x-slot />
</button>

With the above, class is owned by the component. Pass class="something-else" from the call site and the component's expression takes precedence. id and style, having no declaration on the root, still fall through as before.

The new :apply attribute

For cases where you want full manual control — spreading additional attributes, filtering specific ones, or building the attribute set dynamically — the new :apply attribute hands you that control explicitly.

Placing :apply anywhere in a component template disables automatic fallthrough for that component entirely. The $attributes variable, an ImmutableArray of everything passed at the call site, is always available:

<button :apply="$attributes">
    <x-slot />
</button>

Because $attributes is an ImmutableArray, you can filter it before spreading. To exclude specific attributes:

<button :apply="$attributes->diffKeys(array_flip(['id', 'style']))">
    <x-slot />
</button>

To include only specific attributes:

<button :apply="$attributes->intersectKeys(array_flip(['class']))">
    <x-slot />
</button>

You can also build the array entirely yourself in a PHP preamble and pass it in, which pairs naturally with the :as attribute for components that conditionally render as different elements:

<?php
$apply = [
    'class' => $class ?? null,
    'href'  => $href ?? '',
    'target' => (isset($href) && str_contains($href, 'http')) ? '_blank' : null,
];
?>
<button :as="$apply['href'] !== '' ? 'a' : 'button'" :apply="$apply">
    {{ $label ?? '' }}
</button>

:apply stringifies attribute values according to the following rules: true emits a bare attribute name; false, null, and empty string are omitted; everything else is rendered as name="value".

@github-actions
Copy link
Copy Markdown

github-actions bot commented Mar 30, 2026

Benchmark Results

Comparison of 3.x-applyFallthroughAttributes against 3.x (ba925951610e8987599a77663e7d8e779b8af89d).

Open to see the benchmark results

No benchmark changes above ±5%.

Generated by phpbench against commit df1d4d4

@iamdadmin iamdadmin changed the title fix(view)!: resolve inconsistent fallthrough attributes and introduce apply attribute for full control fix(view)!: fix inconsistent fallthrough attributes and introduce apply attribute for full control Mar 30, 2026
@iamdadmin
Copy link
Copy Markdown
Contributor Author

Weird, I can't replicate the run style check's unused view import. Locally mago on the same version doesn't find any unused view imports, and the logs don't show where. I'll do some digging.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

applyFallthroughAttributes doesn't apply when a <?php preamble is present

1 participant