Skip to content

Dual-render widgets: BaseBladeWidget + BaseLivewireWidget#37

Merged
chasecmiller merged 4 commits into
mainfrom
feature/seo-meta-rendering
May 1, 2026
Merged

Dual-render widgets: BaseBladeWidget + BaseLivewireWidget#37
chasecmiller merged 4 commits into
mainfrom
feature/seo-meta-rendering

Conversation

@chasecmiller
Copy link
Copy Markdown
Contributor

@chasecmiller chasecmiller commented May 1, 2026

Summary

Introduces a second rendering path for Layup widgets: alongside the existing Blade view component path, widgets can now opt in to rendering through a Livewire component. Same Widget contract, same editor experience, same registration -- only the frontend render path differs. A downstream package can swap any built-in widget that ships with an identity trait (currently Heading, Button, NumberCounter, Newsletter) to a Livewire flavour without redeclaring form schema, defaults, or preview.

This is purely additive. BaseWidget is preserved as an abstract alias; all 100+ existing widgets, every stub, every doc reference, and every downstream extension continues to work without modification. All 1130 existing tests pass unchanged.

Why

Layup's widget system has always been excellent for static content: editors arrange widgets, the page renders to fixed HTML on the server, Alpine handles light interactivity. That model breaks down for widgets that need genuine server-side state -- live counters, contact forms with server validation, search-as-you-type, polling notifiers, multi-step flows. Embedding @livewire(...) calls inside individual Blade widget views was possible but ugly: it leaks Livewire wiring into every widget that wants state, doesn't compose cleanly with children, and gives no way for a downstream package to swap a widget's render path without editing the widget itself.

The goal: keep Blade as the default (zero overhead, zero new dependencies for the 95% case), make Livewire a first-class opt-in for the 5%, and design the contract so progressive replacement is a one-class change.

Architecture

Three orthogonal concerns, each with a single home:

  1. Identity & editor experience -- type, label, icon, category, form schema, defaults, preview, validation, search terms, lifecycle hooks. Lives in either the widget class or, for widgets that ship in multiple render flavours, an identity trait in Crumbls\Layup\View\Concerns\Identity\.
  2. Rendering technology -- how the widget produces HTML for the frontend. Lives in the base class: BaseBladeWidget for the default Blade path, BaseLivewireWidget for the Livewire path.
  3. Data plumbing -- $data, $children, position-in-parent, recursive child rendering. Lives in BaseView, which both bases extend.
Crumbls\Layup\Contracts\Widget               (interface, rendering-agnostic)
                |
                v
Crumbls\Layup\View\BaseView                  (data plumbing: $data, $children)
                |
        +-------+--------+
        |                |
BaseBladeWidget    BaseLivewireWidget
        |                |
   (your widget)    (your widget)

BaseWidget = abstract alias for BaseBladeWidget (back-compat shim).

The Widget interface is the source of truth for what a widget is. Both bases implement it. Anywhere code accepts "a widget" -- WidgetRegistry::register(), content serialization in LayupContent, test assertions in LayupAssertions, auto-discovery in RegistersWidgets -- it now accepts either flavour. Layup's own internals were updated to check for instanceof Widget instead of instanceof BaseWidget so the polymorphism actually holds.

What changed

New: BaseLivewireWidget

Crumbls\Layup\View\BaseLivewireWidget is the sibling of BaseBladeWidget. It implements the same Widget contract via the same HasWidgetDefaults trait. Its render() mounts a Livewire component via <livewire:dynamic-component>:

public function render(): string
{
    return Blade::render(
        '<livewire:dynamic-component
            :component=\"$component\"
            :wire:key=\"$key\"
            :data=\"$data\"
        >{!! $childrenHtml !!}</livewire:dynamic-component>',
        [
            'component' => static::getLivewireComponent(),
            'key' => static::getType() . '-' . spl_object_id($this),
            'data' => $this->data,
            'childrenHtml' => $this->renderChildrenToHtml(),
        ]
    );
}

The widget's $data is passed as a Livewire prop (typed as array on the Livewire component). Children are pre-rendered to HTML on the server (recursing through BaseView::renderChildrenToHtml()) and passed as the default Livewire slot, so children remain polymorphic -- a child of a Livewire widget can be a Blade widget, another Livewire widget, or any mix. Each child manages its own rendering.

The base does not import any class from livewire/livewire. It uses Blade::render() with the <livewire:dynamic-component> tag string, so the package autoloads cleanly even when Livewire isn't installed. livewire/livewire is declared in composer.json suggest (not require).

New: BaseBladeWidget

Crumbls\Layup\View\BaseBladeWidget is the renamed body of the previous BaseWidget. The default Blade rendering path is unchanged: view('layup::components.{type}', ['data' => $data, 'children' => $children]). The getViewName() override hook is preserved.

The render return type is widened from Illuminate\Contracts\View\View to View|Htmlable|string. Existing Blade widgets are unaffected (returning a View still satisfies the wider type via covariance); the wider type lets BaseLivewireWidget::render() return a string. Subclasses that previously declared : View still work because PHP allows narrowing in subtypes.

Back-compat: BaseWidget

Crumbls\Layup\View\BaseWidget is now an abstract alias that extends BaseBladeWidget:

abstract class BaseWidget extends BaseBladeWidget {}

Every existing widget (extends BaseWidget), stub, doc reference, and downstream extension continues to work without modification. instanceof BaseWidget and is_subclass_of(..., BaseWidget::class) still match all current widgets. The only thing they don't match is BaseLivewireWidget subclasses, which is correct -- those have a different render path.

New: HasWidgetDefaults trait

Crumbls\Layup\View\Concerns\HasWidgetDefaults extracts the static metadata defaults shared between both bases: getIcon(), getCategory(), getDefaultData(), getPreview(), prepareForRender(), getValidationRules(), getSearchTerms(), isDeprecated(), getDeprecationMessage(), lifecycle hooks (onSave, onCreate, onDelete, onDuplicate), getAssets(), toArray(). Both BaseBladeWidget and BaseLivewireWidget use this trait. Avoids duplicating ~140 lines of identical default behaviour across the two bases.

New: identity traits

Crumbls\Layup\View\Concerns\Identity\ ships per-widget identity traits for select widgets that may have multiple render flavours:

  • HeadingIdentity
  • ButtonIdentity
  • NumberCounterIdentity
  • NewsletterIdentity

Each trait holds the static metadata for one widget -- type, label, icon, category, form schema, defaults, preview -- with no rendering logic. The corresponding built-in widget class (HeadingWidget, ButtonWidget, etc.) is now a four-line shell:

class HeadingWidget extends BaseWidget
{
    use HeadingIdentity;
}

A downstream package ships a Livewire flavour by extending BaseLivewireWidget and pulling in the same trait:

class HeadingLivewireWidget extends BaseLivewireWidget
{
    use HeadingIdentity;

    public static function getLivewireComponent(): string
    {
        return 'my-package.heading';
    }
}

Same getType() returns 'heading', same form, same defaults. Different render path. Register the Livewire flavour against the same WidgetRegistry and the existing override-with-warning behaviour (WidgetRegistry::register() line 32-34) handles the swap. The remaining ~95 built-in widgets are untouched -- the identity-trait pattern is opt-in per widget and only matters when shipping multiple render flavours of the same widget.

New: BaseView::renderChildrenToHtml()

public function renderChildrenToHtml(): string
{
    $html = '';
    foreach ($this->children as $child) {
        $html .= (string) $child->render();
    }
    return $html;
}

Used by BaseLivewireWidget::render() to produce the slot content. Available on every BaseView subclass for any custom render path that needs pre-rendered children.

Internal type-checking switched to interface

Every place in the package that introspects widgets at runtime was switched from instanceof BaseWidget / is_subclass_of(..., BaseWidget::class) to interface-based instanceof Widget / is_subclass_of(..., Widget::class):

  • Crumbls\Layup\Support\Concerns\RegistersWidgets::discoverAppWidgets() (auto-discovery for app/Layup/Widgets/)
  • Crumbls\Layup\Support\LayupContent::serializeNodes() and ::resolveNodeType() (content tree serialization)
  • Crumbls\Layup\Testing\LayupAssertions (annotations and helpers)
  • tests/Unit/WidgetDefaultCompletenessTest::discoverShippedWidgets() (test discovery)

This is the load-bearing change for polymorphism: without it, Livewire widgets would be invisible to auto-discovery and serialization. The interface check matches both bases. Existing code that does instanceof BaseWidget still works for Blade widgets only -- downstream code that wants to support Livewire widgets should follow the same interface-based pattern, but no immediate change is required.

Test helper updates

Crumbls\Layup\Testing\LayupAssertions::assertWidgetRenders() and assertWidgetRendersWithDefaults() previously called $widget->render()->render() -- assuming render() returns a View whose own render() produces the string. With the wider return type, the inner call could be a string or an Htmlable instead. A new renderToString(View|Htmlable|string) protected helper coerces all three into a string before assertion. Existing tests that call $widget->render()->toHtml() directly continue to work because Blade widgets still return a View (which implements Htmlable).

Docs

Three doc files added or expanded:

  • docs/advanced/livewire-widgets.md (new, 280 lines) -- comprehensive guide covering the architecture overview with diagram, when-to-use-which decision matrix, anatomy of a Livewire-rendered widget (widget class + Livewire component + Blade view), the render flow internals, the slot-based children model, the identity-trait pattern for multi-flavour widgets, migration steps for existing custom widgets, asset-pipeline considerations, editor preview vs. frontend render, performance considerations, testing patterns, security notes, installation, and full API reference.
  • docs/api-reference/widget-contract.md (expanded) -- now documents BaseBladeWidget, BaseLivewireWidget, HasWidgetDefaults, the identity-trait registry, the BaseView::renderChildrenToHtml() helper, and the runtime type-checking guidance (instanceof Widget over instanceof BaseWidget).
  • docs/advanced/extending-widgets.md -- adds a callout pointing at the Livewire docs for render-tech swaps, since the four widgets with identity traits can be swapped without redeclaring metadata.
  • docs/advanced/custom-widgets.md -- one-line callout pointing at the Livewire docs.
  • CHANGELOG.md -- detailed Unreleased entry under Added and Changed.

What did not change

  • The Widget interface in Crumbls\Layup\Contracts\Widget. Identical, byte-for-byte.
  • The BaseView design tab schema, advanced tab schema, form-tab structure, helper methods (visibilityClasses, buildInlineStyles, animationAttributes, walkComponents, etc.).
  • The Filament editor experience -- form rendering, builder canvas, slideovers, all of it.
  • The frontend rendering pipeline -- frontend/page.blade.php, frontend/loop.blade.php, components/section.blade.php, etc. The call site ({!! $widget->render() !!}) is unchanged because it already stringifies whatever comes back.
  • Content storage format -- the JSON shape of saved pages is unchanged.
  • The 1130 existing tests. Every one passes unchanged. No tests were modified except the discovery test (WidgetDefaultCompletenessTest), which switched from BaseWidget class checks to Widget interface checks while still asserting the same behaviour.
  • Auto-discovery semantics -- app/Layup/Widgets/ discovery still finds every widget that previously worked, plus now finds Livewire widgets too.
  • Stub generators (stubs/layup-widget.php.stub) -- still extend BaseWidget. Generated widgets work without modification because BaseWidget is now BaseBladeWidget under a different name.
  • Public method signatures on any concrete widget class. The four refactored widgets (HeadingWidget, ButtonWidget, NumberCounterWidget, NewsletterWidget) have identical static APIs -- traits inline their methods into the using class, so HeadingWidget::getType() resolves the same as before.
  • composer.json require block. livewire/livewire is declared only in the new suggest block.

Migration notes

For users who only ever use Blade widgets: no action required. Continue extending BaseWidget. The only thing you'll see is the new livewire-widgets.md page in the docs.

For users who want to add a Livewire widget:

  1. composer require livewire/livewire.
  2. Create your widget class extending BaseLivewireWidget. Implement getType(), getLabel(), getLivewireComponent(), plus whatever metadata you'd put on a Blade widget.
  3. Build the Livewire component normally (separate class, public array $data prop, your state, your mount() and actions, a Blade view that emits {{ $data['...'] }} and {{ $slot }} for children).
  4. Register the widget in config/layup.php widgets or programmatically via WidgetRegistry::register().

For users who want to swap a built-in widget for a Livewire flavour: see the new docs/advanced/livewire-widgets.md#identity-traits-shipping-multiple-flavours-of-the-same-widget section. The four widgets with identity traits can be swapped without redeclaring metadata; for any other built-in, you'd need to re-implement the form schema and defaults yourself.

For downstream code that does runtime widget introspection (custom registries, content walkers): if you check instanceof BaseWidget or is_subclass_of(..., BaseWidget::class) and want Livewire widgets to be visible, switch to the Widget interface. If you don't intend to support Livewire widgets, no change required.

Test plan

  • vendor/bin/pest -- 1130 passed, 3563 assertions, 26s.
  • vendor/bin/pint --test -- all clean across src/, tests/, docs/, CHANGELOG.md.
  • PHP lint pass on every changed file.
  • Manually verified: HeadingWidget::getType(), ButtonWidget::getDefaultData(), NumberCounterWidget::getContentFormSchema(), NewsletterWidget::getPreview() all resolve identically to pre-refactor.
  • Manually verified: BaseWidget shim is recognised as parent of all existing widgets via is_subclass_of().
  • Manually verified: render output for the four refactored widgets is byte-identical to pre-refactor (covered by tests/Unit/RenderingTest.php + tests/Feature/PageControllerTest.php rendering assertions).
  • Manually verified: WidgetRegistry::register() accepts both bases and surfaces the override-warning when a downstream class re-registers an existing type.
  • End-to-end Livewire widget on a live page -- requires a host app with Livewire installed; recommend a follow-up integration test in a downstream consumer once a real Livewire flavour exists.

Security note

  • No new permission boundaries. The Livewire path runs with the same authorization context as the rest of the page render: anyone who can load the page can mount and interact with the Livewire components on it. Any wire:click / wire:submit handler that mutates state or performs authenticated work must gate inside the Livewire component (Auth::check() / policy checks). The page builder is public; the docs page covers this explicitly under "Security".
  • Editor-provided strings flow into Livewire props verbatim. Livewire components must escape with {{ }} (default) and avoid {!! !!} for editor-controlled fields unless the editor sanitises them. Same posture as Blade widgets today.

Performance note

  • Blade widgets: zero added overhead. The BaseWidgetBaseBladeWidget rename is a one-level inheritance hop with no behavioural change. The widened render() return type adds nothing at runtime.
  • Livewire widgets: each rendered Livewire widget incurs one Blade::render() call to mount, one Livewire mount() invocation, one component render() invocation server-side, plus a wire:id wrapper and morphdom diffing client-side, plus a Livewire backend round-trip per action. For pages with one or two interactive widgets among static content, unnoticeable. For pages full of Livewire widgets, the docs recommend reconsidering whether the page-builder model is the right tool.
  • Children of a Livewire widget are pre-rendered to a string once at parent mount time and preserved across parent re-renders by Livewire's slot mechanism -- they do not re-execute on the server when the parent's state changes. This matches the natural model where children are page structure, not parent state.

OpenAPI / API contract changes

None. No HTTP routes added, removed, or modified. No request/response shapes changed.

Database / migration changes

None. No schema changes, no data migrations.

Rollback plan

This PR is purely additive at runtime. To roll back:

  1. Revert the two commits in this PR: git revert fe0c161 0ca1616.
  2. Or, if a Livewire-flavoured widget has been deployed downstream, re-register the Blade flavour and ship a follow-up commit before reverting -- the override warning surfaces in logs, so the swap is visible.

BaseWidget continues to exist after revert. No downstream code that extends BaseWidget would need to change either way.

Risk assessment

  • Low for everything related to existing Blade widgets, the editor, content storage, frontend rendering, and tests. The 1130-test suite passes unchanged. BaseWidget is preserved. All existing public APIs are intact.
  • Medium for the Livewire path itself, until a real downstream consumer exercises it end-to-end. The unit-level surface is well-tested via the contract assertions, but Livewire integration depends on the host app's Livewire setup and livewire/livewire is a soft dependency. The docs call this out; recommend a follow-up integration test in the first downstream consumer.

Out of scope

  • Migrating the remaining ~95 built-in widgets to identity traits. Mechanical work; can happen in batches as widgets gain Livewire flavours that need the trait. The four widgets refactored in this PR demonstrate the pattern.
  • Building actual Livewire-flavoured built-in widgets. The framework is in place; a downstream package (or future PR) would ship the Livewire components themselves.
  • A make:layup-widget --livewire scaffolding flag. Existing make:layup-widget continues to scaffold Blade widgets; a Livewire variant could be added later if usage warrants it.
  • Asset pipeline integration between Layup's getAssets() and Livewire's @assets directives. Both work; the docs explain when to use which. A unified declaration could be added later.

Summary by CodeRabbit

Release Notes

  • New Features

    • Widgets now support dual rendering modes: traditional Blade templates (default) or Livewire components (opt-in).
    • New livewire/livewire soft dependency enables Livewire-based widget rendering.
  • Documentation

    • Added comprehensive guide for Livewire widget configuration and best practices.
    • Enhanced widget customization docs with rendering architecture options.
  • Refactor

    • Refactored built-in widgets to use shared identity patterns for cleaner configuration management.

Splits the widget rendering tech from widget identity so a downstream
package can swap any widget's frontend render path from Blade to Livewire
without redeclaring form schema, defaults, lifecycle hooks, or preview.

- Extract HasWidgetDefaults trait holding all static metadata defaults
  shared between Blade and Livewire bases.
- Rename current BaseWidget body to BaseBladeWidget; keep BaseWidget as
  a back-compat shim so existing widgets and stubs are untouched.
- Add BaseLivewireWidget that mounts a Livewire component via
  <livewire:dynamic-component>, passing $data as a prop and pre-rendered
  children as the default slot. livewire/livewire stays a soft dependency
  (composer suggest).
- Widen render() return type on BaseView to View|Htmlable|string so both
  bases satisfy the contract; the call site already stringifies output.
- Add BaseView::renderChildrenToHtml() for the Livewire slot path.
- Switch type checks in RegistersWidgets, LayupContent, and
  WidgetDefaultCompletenessTest from BaseWidget class to the Widget
  interface so Livewire widgets are recognised everywhere.
- Update LayupAssertions to coerce View|Htmlable|string render results.
- Demonstrate the identity-trait pattern on Heading, Button,
  NumberCounter, and Newsletter; remaining widgets unchanged.
- Add docs/advanced/livewire-widgets.md describing when to use which
  base, the slot-based children model, and how a downstream package
  re-registers a type to swap render tech.

All 1130 tests pass.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 1, 2026

Warning

Rate limit exceeded

@chasecmiller has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 39 minutes and 25 seconds before requesting another review.

To keep reviews running without waiting, you can enable usage-based add-on for your organization. This allows additional reviews beyond the hourly cap. Account admins can enable it under billing.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b0d43e87-eb39-416e-8785-e2dff5ad7173

📥 Commits

Reviewing files that changed from the base of the PR and between fe0c161 and 76189b2.

📒 Files selected for processing (7)
  • docs/advanced/livewire-widgets.md
  • src/View/BaseView.php
  • tests/TestCase.php
  • tests/Unit/BaseLivewireWidgetTest.php
  • tests/Unit/IdentityTraitCompositionTest.php
  • tests/Unit/RenderChildrenToHtmlTest.php
  • tests/views/render-child-fixture.blade.php
📝 Walkthrough

Walkthrough

The changes introduce a dual rendering architecture enabling widgets to render via either Blade (default) or Livewire through opt-in BaseLivewireWidget, while preserving the Widget contract and editor/registration flow. This includes new base/alias structure, shared defaults extraction via traits, per-widget identity traits, and widened return types to support both rendering paths.

Changes

Cohort / File(s) Summary
Documentation
CHANGELOG.md, docs/advanced/custom-widgets.md, docs/advanced/extending-widgets.md, docs/advanced/livewire-widgets.md, docs/api-reference/widget-contract.md
Comprehensive documentation of new dual rendering architecture, Livewire widget configuration, identity traits, and updated widget contract guidance.
Dependency Metadata
composer.json
Adds livewire/livewire as a soft suggest dependency for Livewire-based widget rendering.
Core Architecture
src/View/BaseView.php, src/View/BaseWidget.php, src/View/BaseBladeWidget.php, src/View/BaseLivewireWidget.php
Introduces dual rendering bases (BaseBladeWidget for Blade, BaseLivewireWidget for Livewire), refactors BaseWidget as an alias of BaseBladeWidget, and widens render() return type to View|Htmlable|string with new renderChildrenToHtml() helper.
Shared Defaults & Identity Traits
src/View/Concerns/HasWidgetDefaults.php, src/View/Concerns/Identity/ButtonIdentity.php, src/View/Concerns/Identity/HeadingIdentity.php, src/View/Concerns/Identity/NewsletterIdentity.php, src/View/Concerns/Identity/NumberCounterIdentity.php
New HasWidgetDefaults trait centralizes default widget behavior; identity traits extract and share metadata/schema/preview logic for button, heading, newsletter, and number-counter widgets.
Built-in Widget Refactoring
src/View/ButtonWidget.php, src/View/HeadingWidget.php, src/View/NewsletterWidget.php, src/View/NumberCounterWidget.php
Four built-in widgets refactored to use identity traits, removing inline metadata and form schema definitions.
Runtime & Testing Support
src/Support/Concerns/RegistersWidgets.php, src/Support/LayupContent.php, src/Testing/LayupAssertions.php, tests/Unit/WidgetDefaultCompletenessTest.php
Widget discovery and registration now target the Widget interface instead of BaseWidget lineage, with abstract-class filtering; testing assertions coerce widened render return types to strings via new renderToString() helper.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 Behold! A widget now can choose its path,
Blade or Livewire—no monolithic wrath.
Traits compose identity with grace,
Children render smoothly in their place.
Hopping forward into futures bright!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.86% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Dual-render widgets: BaseBladeWidget + BaseLivewireWidget' clearly and specifically describes the main architectural change introduced in the pull request: adding support for dual rendering paths (Blade and Livewire) through two new/refactored base widget classes.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/seo-meta-rendering

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
Review rate limit: 0/1 reviews remaining, refill in 39 minutes and 25 seconds.

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
src/View/BaseLivewireWidget.php (1)

57-75: Add an explicit Livewire dependency guard in render().

Line 69 currently defers missing-dependency failure to Blade::render(), producing an opaque error. A direct pre-check at the method entry point provides immediate, actionable diagnostics for this soft dependency.

The class_exists(\Livewire\Livewire::class) pattern is consistent with how the codebase validates optional dependencies elsewhere (e.g., InstallCommand).

Proposed change
 public function render(): string
 {
+    if (! class_exists(\Livewire\Livewire::class)) {
+        throw new \RuntimeException(
+            'BaseLivewireWidget requires livewire/livewire. Install it with: composer require livewire/livewire'
+        );
+    }
+
     $template = <<<'BLADE'
         <livewire:dynamic-component
             :component="$component"
             :wire:key="$key"
             :data="$data"
         >
             {!! $childrenHtml !!}
         </livewire:dynamic-component>
     BLADE;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/View/BaseLivewireWidget.php` around lines 57 - 75, The render() method
defers a missing Livewire dependency to Blade::render(), causing an opaque
error; add an explicit guard at the start of BaseLivewireWidget::render() that
checks class_exists(\Livewire\Livewire::class) and throws a clear exception (or
uses existing helper) when Livewire is not installed, mirroring the pattern used
in InstallCommand; locate the render() method and insert this pre-check before
constructing $template so callers get an immediate, actionable error if Livewire
is missing.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@docs/advanced/livewire-widgets.md`:
- Around line 22-35: Add a fenced-code language specifier ("text") to the two
code fences in docs/advanced/livewire-widgets.md that currently start without
one: the ASCII diagram block beginning with "Crumbls\Layup\Contracts\Widget"
(the diagram used to describe BaseBladeWidget/BaseLivewireWidget) and the
log/example block that begins with "Layup: Widget type 'newsletter' already
registered..."; replace the opening ``` with ```text for both blocks so
markdownlint MD040 is satisfied while preserving the existing fence content and
closing backticks.

In `@src/View/BaseView.php`:
- Around line 95-112: renderChildrenToHtml() currently casts each child's
render() result to string which breaks when render() returns an Htmlable that
lacks __toString(); change the concatenation to detect Htmlable results and call
toHtml() instead of string-casting: call $child->render(), if the result is an
instance of \Illuminate\Contracts\Support\Htmlable (or has toHtml()), append
result->toHtml(), otherwise append (string) result; update the use/imports as
needed and keep behavior for View and string returns unchanged.

---

Nitpick comments:
In `@src/View/BaseLivewireWidget.php`:
- Around line 57-75: The render() method defers a missing Livewire dependency to
Blade::render(), causing an opaque error; add an explicit guard at the start of
BaseLivewireWidget::render() that checks class_exists(\Livewire\Livewire::class)
and throws a clear exception (or uses existing helper) when Livewire is not
installed, mirroring the pattern used in InstallCommand; locate the render()
method and insert this pre-check before constructing $template so callers get an
immediate, actionable error if Livewire is missing.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9f4590c4-d98a-4116-aa38-fdd9afaa32e2

📥 Commits

Reviewing files that changed from the base of the PR and between 6c46905 and fe0c161.

📒 Files selected for processing (23)
  • CHANGELOG.md
  • composer.json
  • docs/advanced/custom-widgets.md
  • docs/advanced/extending-widgets.md
  • docs/advanced/livewire-widgets.md
  • docs/api-reference/widget-contract.md
  • src/Support/Concerns/RegistersWidgets.php
  • src/Support/LayupContent.php
  • src/Testing/LayupAssertions.php
  • src/View/BaseBladeWidget.php
  • src/View/BaseLivewireWidget.php
  • src/View/BaseView.php
  • src/View/BaseWidget.php
  • src/View/ButtonWidget.php
  • src/View/Concerns/HasWidgetDefaults.php
  • src/View/Concerns/Identity/ButtonIdentity.php
  • src/View/Concerns/Identity/HeadingIdentity.php
  • src/View/Concerns/Identity/NewsletterIdentity.php
  • src/View/Concerns/Identity/NumberCounterIdentity.php
  • src/View/HeadingWidget.php
  • src/View/NewsletterWidget.php
  • src/View/NumberCounterWidget.php
  • tests/Unit/WidgetDefaultCompletenessTest.php

Comment thread docs/advanced/livewire-widgets.md Outdated
Comment thread src/View/BaseView.php
The first commit shipped BaseLivewireWidget, HasWidgetDefaults, four
identity traits, and switched internal type checks to the Widget
interface -- all without tests. Closing the gap.

- tests/Unit/BaseLivewireWidgetTest.php: contract conformance,
  Widget interface polymorphism, render() output structure including
  pre-rendered children in the slot, registry registration, LayupContent
  serialization, HasWidgetDefaults defaults, lifecycle pass-through.
- tests/Unit/IdentityTraitCompositionTest.php: pins the load-bearing
  refactor claim that "same identity trait + different base = identical
  metadata" by comparing every static method (type, label, icon,
  category, defaults, preview, content form schema field names,
  toArray()) between the four shipped Blade widgets and Livewire-flavour
  fixtures using the same trait.
- tests/Unit/RenderChildrenToHtmlTest.php: exercises BaseView's new
  child-rendering helper across all three legal render() return types
  (View | Htmlable | string) including a heterogeneous mix.
- tests/TestCase.php: register Livewire's service provider so render
  tests can mount real components. Filament normally registers it
  transitively but the Testbench environment only loads what we ask for.

37 new tests, 85 new assertions. Full suite: 1167 passed.
@chasecmiller
Copy link
Copy Markdown
Contributor Author

Test coverage added

Closing a gap in the original PR: the first commit shipped BaseLivewireWidget, HasWidgetDefaults, four identity traits, and the interface-based polymorphism without tests. New commit (f454d28) adds 37 tests covering:

tests/Unit/BaseLivewireWidgetTest.php (8 tests)

  • Contract conformance (implements Widget)
  • Polymorphism: instanceof Widget matches, instanceof BaseWidget does not (sibling render paths)
  • render() output: real Livewire mount via a fake registered component, payload appears in HTML
  • Children in slot: parent's children are pre-rendered and included in the parent's mount output
  • WidgetRegistry::register() accepts the new base
  • LayupContent::toArray() serializes a Livewire widget the same way it serializes a Blade widget
  • HasWidgetDefaults defaults are applied (icon, category, deprecation flags)
  • Lifecycle hooks (onCreate, onSave, onDuplicate, prepareForRender) pass through unchanged

tests/Unit/IdentityTraitCompositionTest.php (24 tests, 4 widgets × 6 invariants)

Pins the load-bearing claim of the refactor: "same identity trait + different base = identical metadata." Constructs a Livewire-flavour fixture for each of the four traits (HeadingIdentity, ButtonIdentity, NumberCounterIdentity, NewsletterIdentity) and asserts that every static metadata method returns identical output to the shipped Blade widget:

  • getType() / getLabel() / getIcon() / getCategory()
  • getDefaultData()
  • getPreview() against default data
  • getContentFormSchema() field names
  • toArray() (whole metadata payload)
  • Both flavours implement Widget; neither is a subclass of the other (sibling bases)

If anyone later inlines render-path-specific logic into one of these traits, the suite will fail.

tests/Unit/RenderChildrenToHtmlTest.php (5 tests)

  • Empty children → empty string
  • String-returning children concatenated in order
  • Htmlable-returning children coerced to string
  • View-returning children coerced to string
  • Heterogeneous mix of all three render return types

This exercises the BaseView::renderChildrenToHtml() helper that drives the Livewire-slot path and pins that all three legal render() return types are interchangeable inside a parent's children list -- the contract that makes mixed Blade+Livewire trees work.

tests/TestCase.php

Registered Livewire\LivewireServiceProvider so test-environment renders can mount real components. Filament normally pulls Livewire in transitively, but the Testbench setup only loads what we ask for.

Totals: 1167 passed (3648 assertions), up from 1130 / 3563 in the original PR. Pint clean across tests/.

- BaseView::renderChildrenToHtml(): Htmlable implementations are not
  required to define __toString(), so the previous (string) cast would
  crash on a custom Htmlable that lacks it. Now branches on
  instanceof Htmlable and calls toHtml() directly. View and string
  returns are unchanged (concrete Illuminate\View\View defines both
  toHtml() and __toString()). New regression test in
  RenderChildrenToHtmlTest exercises an inline anonymous Htmlable
  that intentionally has no __toString().
- docs/advanced/livewire-widgets.md: tag the architecture-diagram and
  log-output fenced blocks with `text` so markdownlint MD040 passes.

Suite: 1168 passed.
@chasecmiller chasecmiller merged commit 7368122 into main May 1, 2026
1 of 2 checks passed
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.

1 participant