diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000..f5c4d3f
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,44 @@
+name: tests
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+
+ strategy:
+ fail-fast: false
+ matrix:
+ php: [8.3, 8.4]
+
+ name: PHP ${{ matrix.php }}
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php }}
+ extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, bcmath, soap, intl, gd, exif, iconv
+ coverage: none
+
+ - name: Install dependencies
+ run: |
+ composer config allow-plugins.pestphp/pest-plugin true
+ composer install --prefer-dist --no-interaction
+
+ - name: Install Playwright
+ run: |
+ npm install playwright@latest
+ npx playwright install --with-deps
+
+ - name: Execute tests
+ run: vendor/bin/pest
diff --git a/.gitignore b/.gitignore
index ffb9d24..d4829bf 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,3 +4,5 @@ vendor/
composer.lock
.idea
.DS_Store
+tests/Browser/Screenshots
+node_modules
diff --git a/composer.json b/composer.json
index 8a27f37..52ed6f9 100644
--- a/composer.json
+++ b/composer.json
@@ -3,7 +3,7 @@
"type": "library",
"license": "MIT",
"homepage": "https://github.com/power-components/partials",
- "minimum-stability": "dev",
+ "minimum-stability": "stable",
"authors": [
{
"name": "Luan Freitas",
@@ -23,11 +23,29 @@
"src/functions.php"
]
},
+ "autoload-dev": {
+ "psr-4": {
+ "Tests\\": "tests/"
+ }
+ },
"extra": {
"laravel": {
"providers": [
"PowerComponents\\Partials\\PartialsServiceProvider"
]
}
+ },
+ "require-dev": {
+ "pestphp/pest": "^4.0",
+ "pestphp/pest-plugin-laravel": "^4.0",
+ "orchestra/testbench": "^10.0",
+ "pestphp/pest-plugin-browser": "^4.0",
+ "laravel/pint": "^1.29",
+ "mockery/mockery": "^1.6.12"
+ },
+ "config": {
+ "allow-plugins": {
+ "pestphp/pest-plugin": true
+ }
}
}
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..f56f41b
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,60 @@
+{
+ "name": "partials",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "partials",
+ "version": "1.0.0",
+ "license": "ISC",
+ "dependencies": {
+ "playwright": "^1.60.0"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/playwright": {
+ "version": "1.60.0",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
+ "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.60.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.60.0",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
+ "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..9be7697
--- /dev/null
+++ b/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "partials",
+ "version": "1.0.0",
+ "description": "Livewire Partials provide a structured and explicit way to update **specific DOM fragments** of a Livewire component instead of re-rendering the entire component tree. This is especially useful for complex components such as data tables, where partial updates significantly improve performance and user experience.",
+ "homepage": "https://github.com/Power-Components/partials#readme",
+ "bugs": {
+ "url": "https://github.com/Power-Components/partials/issues"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/Power-Components/partials.git"
+ },
+ "license": "ISC",
+ "author": "",
+ "type": "commonjs",
+ "main": "index.js",
+ "directories": {
+ "test": "tests"
+ },
+ "scripts": {
+ "test": "echo \"Error: no test specified\" && exit 1"
+ },
+ "dependencies": {
+ "playwright": "^1.60.0"
+ }
+}
diff --git a/phpunit.xml b/phpunit.xml
new file mode 100644
index 0000000..0b4810c
--- /dev/null
+++ b/phpunit.xml
@@ -0,0 +1,32 @@
+
+
+
+
+ tests/Feature
+
+
+ tests/Browser
+
+
+
+
+ src
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/resources/js/partials.js b/resources/js/partials.js
index 9d70c16..0e79c96 100644
--- a/resources/js/partials.js
+++ b/resources/js/partials.js
@@ -26,10 +26,6 @@ document.addEventListener('livewire:init', () => {
continue
}
- if (els.length > 1) {
- throw `Multiple elements found for partial [${name}].`
- }
-
let el = els[0]
const getIgnoreKey = (node) => {
diff --git a/src/PartialsHook.php b/src/PartialsHook.php
index cc2f4d5..d61e666 100644
--- a/src/PartialsHook.php
+++ b/src/PartialsHook.php
@@ -4,9 +4,11 @@
use Closure;
use Illuminate\View\View;
-use Livewire\{Component, ComponentHook};
+use Livewire\Component;
+use Livewire\ComponentHook;
use Livewire\Drawer\Utils;
-use Livewire\Mechanisms\HandleComponents\{ComponentContext, ViewContext};
+use Livewire\Mechanisms\HandleComponents\ComponentContext;
+use Livewire\Mechanisms\HandleComponents\ViewContext;
use PowerComponents\Partials\Attribute\PartialRender;
use ReflectionMethod;
diff --git a/src/PartialsServiceProvider.php b/src/PartialsServiceProvider.php
index 1558827..f866c16 100644
--- a/src/PartialsServiceProvider.php
+++ b/src/PartialsServiceProvider.php
@@ -2,6 +2,7 @@
namespace PowerComponents\Partials;
+use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider;
use Livewire\Mechanisms\DataStore;
@@ -10,6 +11,22 @@ class PartialsServiceProvider extends ServiceProvider
public function boot(): void
{
$this->publishConfigs();
+ $this->registerRoutes();
+ }
+
+ private function registerRoutes(): void
+ {
+ Route::get('/powergrid-partials/partials.js', function () {
+ return response(file_get_contents(__DIR__.'/../resources/js/partials.js'), 200, [
+ 'Content-Type' => 'application/javascript',
+ ]);
+ });
+
+ Route::get('/powergrid-partials/utils.js', function () {
+ return response(file_get_contents(__DIR__.'/../resources/js/utils.js'), 200, [
+ 'Content-Type' => 'application/javascript',
+ ]);
+ });
}
public function register(): void
diff --git a/tests/Browser/PartialsBrowserTest.php b/tests/Browser/PartialsBrowserTest.php
new file mode 100644
index 0000000..a3e51cb
--- /dev/null
+++ b/tests/Browser/PartialsBrowserTest.php
@@ -0,0 +1,309 @@
+mainId = uniqid();
+ }
+
+ #[PartialRender('partial-count', 'count-partial')]
+ public function increment(): void
+ {
+ $this->count++;
+ }
+
+ public function render()
+ {
+ return <<<'BLADE'
+
+
Main: {{ $mainId }}
+
Main Uniqid: {{ uniqid() }}
+
+
+ @include('partial-count', ['__partial' => $this])
+
+
+
+
+BLADE;
+ }
+}
+
+class PartialsIgnoreBrowserTest extends Component
+{
+ public int $count = 0;
+
+ #[PartialRender('partial-ignore', 'ignore-partial')]
+ public function increment()
+ {
+ $this->count++;
+ }
+
+ public function render()
+ {
+ return <<<'BLADE'
+
+
+ @include('partial-ignore', ['__partial' => $this])
+
+
+
+BLADE;
+ }
+}
+
+class PartialsMultipleBrowserTest extends Component
+{
+ public int $countA = 0;
+
+ public int $countB = 0;
+
+ public string $mainUniqid;
+
+ public function mount(): void
+ {
+ $this->mainUniqid = uniqid();
+ }
+
+ #[PartialRender('partial-count-a', 'partial-a')]
+ public function incrementA(): void
+ {
+ $this->countA++;
+ }
+
+ #[PartialRender('partial-count-b', 'partial-b')]
+ public function incrementB(): void
+ {
+ $this->countB++;
+ }
+
+ public function render()
+ {
+ return <<<'BLADE'
+
+
{{ $mainUniqid }}
+
+
+ @include('partial-count-a', ['__partial' => $this])
+
+
+
+ @include('partial-count-b', ['__partial' => $this])
+
+
+
+
+
+BLADE;
+ }
+}
+
+beforeEach(function () {
+ Livewire::component('browser-partial-component', PartialsBrowserTest::class);
+ Livewire::component('browser-ignore-component', PartialsIgnoreBrowserTest::class);
+ Livewire::component('browser-multiple-component', PartialsMultipleBrowserTest::class);
+
+ Route::get('/browser-test', fn () => Blade::render('
+
+ @livewireStyles
+
+
+ @livewireScripts
+
+
+
+ '))->middleware('web');
+
+ Route::get('/browser-ignore-test', fn () => Blade::render('
+
+ @livewireStyles
+
+
+ @livewireScripts
+
+
+
+ '))->middleware('web');
+
+ Route::get('/browser-multiple-test', fn () => Blade::render('
+
+ @livewireStyles
+
+
+ @livewireScripts
+
+
+
+ '))->middleware('web');
+});
+
+it('updates only the partial fragment and preserves main content', function () {
+ $page = $this->visit('/browser-test')
+ ->assertSeeIn('#count', '0');
+
+ $initialMainUniqid = $page->text('#main-uniqid');
+ $initialPartialUniqid = $page->text('#partial-uniqid');
+
+ $page->click('#increment')
+ ->waitForEvent('networkidle')
+ ->assertSeeIn('#count', '1');
+
+ expect($page->text('#main-uniqid'))
+ ->toBe($initialMainUniqid)
+ ->and($page->text('#partial-uniqid'))->not->toBe($initialPartialUniqid);
+});
+
+it('accumulates state correctly across multiple increments', function () {
+ $page = $this->visit('/browser-test')
+ ->assertSeeIn('#count', '0');
+
+ foreach (range(1, 3) as $expected) {
+ $page->click('#increment')
+ ->waitForEvent('networkidle')
+ ->assertSeeIn('#count', (string) $expected);
+ }
+});
+
+it('payload contains partialFragments effect and no html effect on partial render', function () {
+ $page = $this->visit('/browser-test');
+
+ $page->script(<<<'JS'
+ () => {
+ window.__livewireEffects = null;
+ Livewire.interceptMessage(({ message, onSuccess }) => {
+ onSuccess(({ payload }) => {
+ window.__livewireEffects = payload.effects;
+ });
+ });
+ }
+ JS);
+
+ $page->click('#increment')
+ ->waitForEvent('networkidle');
+
+ $page->assertScript('() => window.__livewireEffects !== null');
+
+ $hasPartialFragments = $page->script(<<<'JS'
+ () => {
+ const effects = window.__livewireEffects;
+ if (!effects) return false;
+ return !!(effects.partialFragments && Object.keys(effects.partialFragments).length > 0);
+ }
+ JS);
+
+ $hasHtmlEffect = $page->script(<<<'JS'
+ () => {
+ const effects = window.__livewireEffects;
+ if (!effects) return false;
+ return !!effects.html;
+ }
+ JS);
+
+ expect($hasPartialFragments)->toBeTrue()
+ ->and($hasHtmlEffect)->toBeFalse();
+});
+
+it('payload partialFragments contains the correct partial name key', function () {
+ $page = $this->visit('/browser-test');
+
+ $page->script(<<<'JS'
+ () => {
+ window.__livewireEffects = null;
+ Livewire.interceptMessage(({ message, onSuccess }) => {
+ onSuccess(({ payload }) => {
+ window.__livewireEffects = payload.effects;
+ });
+ });
+ }
+ JS);
+
+ $page->click('#increment')
+ ->waitForEvent('networkidle');
+
+ $page->assertScript('() => window.__livewireEffects !== null');
+
+ $partialKeys = $page->script(<<<'JS'
+ () => {
+ const effects = window.__livewireEffects;
+ if (!effects?.partialFragments) return [];
+ return Object.keys(effects.partialFragments);
+ }
+ JS);
+
+ expect($partialKeys)->toContain('count-partial');
+});
+
+it('preserves wire:partial.ignore content after partial update', function () {
+ $page = $this->visit('/browser-ignore-test');
+
+ $ignoredBefore = $page->text('#ignored-content');
+
+ $page->click('#increment')
+ ->waitForEvent('networkidle');
+
+ expect($page->text('#ignored-content'))->toBe($ignoredBefore);
+});
+
+it('updates dynamic content inside partial while ignoring wire:partial.ignore block', function () {
+ $page = $this->visit('/browser-ignore-test')
+ ->assertSeeIn('#count', '0');
+
+ $ignoredBefore = $page->text('#ignored-content');
+
+ $page->click('#increment')
+ ->waitForEvent('networkidle')
+ ->assertSeeIn('#count', '1');
+
+ expect($page->text('#ignored-content'))->toBe($ignoredBefore);
+});
+
+it('updates only the targeted partial when multiple partials exist', function () {
+ $page = $this->visit('/browser-multiple-test')
+ ->assertSeeIn('#count-a', '0')
+ ->assertSeeIn('#count-b', '0');
+
+ $uniqidABefore = $page->text('#partial-uniqid-a');
+ $uniqidBBefore = $page->text('#partial-uniqid-b');
+ $mainUniqid = $page->text('#main-uniqid');
+
+ $page->click('#increment-a')
+ ->waitForEvent('networkidle')
+ ->assertSeeIn('#count-a', '1')
+ ->assertSeeIn('#count-b', '0');
+
+ expect($page->text('#partial-uniqid-a'))->not->toBe($uniqidABefore)
+ ->and($page->text('#partial-uniqid-b'))->toBe($uniqidBBefore)
+ ->and($page->text('#main-uniqid'))->toBe($mainUniqid);
+});
+
+it('updates each partial independently', function () {
+ $page = $this->visit('/browser-multiple-test');
+
+ $page->click('#increment-a')
+ ->waitForEvent('networkidle')
+ ->assertSeeIn('#count-a', '1')
+ ->assertSeeIn('#count-b', '0');
+
+ $page->click('#increment-b')
+ ->waitForEvent('networkidle')
+ ->assertSeeIn('#count-a', '1')
+ ->assertSeeIn('#count-b', '1');
+
+ $page->click('#increment-b')
+ ->waitForEvent('networkidle')
+ ->assertSeeIn('#count-a', '1')
+ ->assertSeeIn('#count-b', '2');
+});
diff --git a/tests/Feature/DataStoreOverrideTest.php b/tests/Feature/DataStoreOverrideTest.php
new file mode 100644
index 0000000..215362d
--- /dev/null
+++ b/tests/Feature/DataStoreOverrideTest.php
@@ -0,0 +1,39 @@
+Dummy';
+ }
+}
+
+it('tests DataStoreOverride when not a livewire request', function () {
+ $store = new DataStoreOverride;
+ $instance = new DummyComponentForDataStore;
+
+ $store->set($instance, 'skipRender', 'some-value');
+ expect($store->get($instance, 'skipRender'))->toBe('some-value');
+});
+
+it('tests DataStoreOverride during livewire request', function () {
+ $store = new DataStoreOverride;
+ $instance = new DummyComponentForDataStore;
+
+ request()->headers->set('X-Livewire', 'true');
+
+ $store->set($instance, 'skipRender', 'some-value');
+
+ app()->bind(PartialsHook::class, function () {
+ throw new Exception('Test exception');
+ });
+
+ expect($store->get($instance, 'skipRender'))->toBeNull();
+
+ request()->headers->remove('X-Livewire');
+ app()->forgetInstance(PartialsHook::class);
+});
diff --git a/tests/Feature/FunctionsTest.php b/tests/Feature/FunctionsTest.php
new file mode 100644
index 0000000..5957555
--- /dev/null
+++ b/tests/Feature/FunctionsTest.php
@@ -0,0 +1,21 @@
+Dummy';
+ }
+}
+
+it('test functions.php partials helper', function () {
+ $hook = partials();
+ expect($hook)->toBeInstanceOf(PartialsHook::class);
+
+ $component = new DummyComponentForFunctions;
+ $hook2 = partials($component);
+ expect($hook2)->toBeInstanceOf(PartialsHook::class);
+});
diff --git a/tests/Feature/PartialsHookTest.php b/tests/Feature/PartialsHookTest.php
new file mode 100644
index 0000000..64dba47
--- /dev/null
+++ b/tests/Feature/PartialsHookTest.php
@@ -0,0 +1,68 @@
+Dummy';
+ }
+
+ #[PartialRender('dummy-partial', 'dummy-view')]
+ protected function protectedIncrement()
+ {
+ // This is to hit the !isPublic() return early
+ }
+}
+
+it('tests PartialsHook edge cases', function () {
+ $hook = app(PartialsHook::class);
+ $component = new DummyComponentForPartialsHook;
+
+ $hook->setComponent($component);
+
+ store($component)->set('forceRender', true);
+ expect($hook->shouldForceRender())->toBeTrue();
+
+ expect($hook->shouldSkipRender())->toBeFalse();
+
+ store($component)->set('forceRender', false);
+ expect($hook->shouldSkipRender())->toBeFalse();
+
+ store($component)->set('partialFragments', [function () {
+ return [];
+ }]);
+
+ expect($hook->shouldSkipRender())->toBeTrue();
+
+ store($component)->set('isPendingPartialRender', false);
+
+ $hook->renderPartial($component, function () {
+ return [];
+ });
+
+ expect(store($component)->get('partialRendersCount'))->toBeNull();
+
+ $hook->partial('my-partial', 'HTML String
');
+ $fragments = store($component)->get('partialFragments');
+ expect($fragments)->toBeArray();
+});
+
+it('tests PartialsHook call method with protected method', function () {
+ $hook = app(PartialsHook::class);
+ $component = new DummyComponentForPartialsHook;
+ $hook->setComponent($component);
+
+ View::addLocation(__DIR__.'/../views');
+
+ $returnEarly = false;
+ $closure = $hook->call('protectedIncrement', [], $returnEarly, null, null);
+
+ expect($returnEarly)->toBeTrue();
+});
diff --git a/tests/Feature/PartialsTest.php b/tests/Feature/PartialsTest.php
new file mode 100644
index 0000000..91bb282
--- /dev/null
+++ b/tests/Feature/PartialsTest.php
@@ -0,0 +1,37 @@
+count++;
+ }
+
+ public function render()
+ {
+ return <<<'BLADE'
+
+
+ {{ $count }}
+
+
+
+BLADE;
+ }
+}
+
+it('can increment count via partial render', function () {
+ Livewire::test(PartialsTest::class)
+ ->assertSet('count', 0)
+ ->call('increment')
+ ->assertSet('count', 1);
+});
diff --git a/tests/Pest.php b/tests/Pest.php
new file mode 100644
index 0000000..29d0948
--- /dev/null
+++ b/tests/Pest.php
@@ -0,0 +1,14 @@
+extend(TestCase::class)
+ ->in('Feature');
+
+pest()->extend(TestCase::class, Browsable::class)
+ ->in('Browser');
+
+pest()->browser()
+ ->inChrome()
+ ->withHost('localhost');
diff --git a/tests/TestCase.php b/tests/TestCase.php
new file mode 100644
index 0000000..c52acc4
--- /dev/null
+++ b/tests/TestCase.php
@@ -0,0 +1,34 @@
+set('database.default', 'testing');
+ $app['config']->set('database.connections.testing', [
+ 'driver' => 'sqlite',
+ 'database' => ':memory:',
+ 'prefix' => '',
+ ]);
+
+ $app['config']->set('app.key', 'base64:Hupx3yAySlyS9lS1k4pXFwZ7eWq2G4c/3gB5G4y7+o4=');
+
+ $app['config']->set('powergrid-partials.enabled', true);
+
+ $app['view']->addLocation(__DIR__.'/views');
+ }
+}
diff --git a/tests/views/partial-count-a.blade.php b/tests/views/partial-count-a.blade.php
new file mode 100644
index 0000000..85b4825
--- /dev/null
+++ b/tests/views/partial-count-a.blade.php
@@ -0,0 +1,4 @@
+
+ {{ $__partial->countA }}
+ {{ uniqid() }}
+
diff --git a/tests/views/partial-count-b.blade.php b/tests/views/partial-count-b.blade.php
new file mode 100644
index 0000000..b2612ca
--- /dev/null
+++ b/tests/views/partial-count-b.blade.php
@@ -0,0 +1,4 @@
+
+ {{ $__partial->countB }}
+ {{ uniqid() }}
+
diff --git a/tests/views/partial-count.blade.php b/tests/views/partial-count.blade.php
new file mode 100644
index 0000000..5405035
--- /dev/null
+++ b/tests/views/partial-count.blade.php
@@ -0,0 +1,4 @@
+
+ {{ $__partial->count }}
+ {{ uniqid() }}
+
diff --git a/tests/views/partial-ignore.blade.php b/tests/views/partial-ignore.blade.php
new file mode 100644
index 0000000..068b746
--- /dev/null
+++ b/tests/views/partial-ignore.blade.php
@@ -0,0 +1,6 @@
+
+
{{ $__partial->count }}
+
+ {{ uniqid() }}
+
+