From edf9f19e893178b131acfdb646de42df2e78e24e Mon Sep 17 00:00:00 2001 From: Malik Alleyne-Jones Date: Tue, 12 May 2026 10:44:09 -0400 Subject: [PATCH 1/3] fix: use stable comment id as livewire :key in comment list (#72) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The list loop previously keyed each child component by `$comment->getContentHash()` — an MD5 of body + reactions that changes whenever a comment is edited or reacted to, and that collides for two comments with identical content. Both cases caused Livewire "Snapshot missing" errors after the keyed component re-keyed itself mid-lifecycle. `getId()` is stable across edits/reactions and unique per row, which is what Livewire keys need. --- resources/views/comment-list.blade.php | 2 +- tests/Livewire/CommentListTest.php | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/resources/views/comment-list.blade.php b/resources/views/comment-list.blade.php index 6ecbf31..5e6725b 100644 --- a/resources/views/comment-list.blade.php +++ b/resources/views/comment-list.blade.php @@ -17,7 +17,7 @@ class="comm:w-8 comm:h-8 comm:text-gray-400 comm:dark:text-gray-500" @foreach ($this->comments as $comment) assertSee('Automated message'); }); +test('CommentList renders duplicate-content comments without key collision', function () { + /** @var User $user */ + $user = User::factory()->create(); + /** @var Post $realPost */ + $realPost = Post::factory()->create(); + + $first = CommentModel::factory()->author($user)->commentable($realPost)->create([ + 'body' => 'identical body', + ]); + $second = CommentModel::factory()->author($user)->commentable($realPost)->create([ + 'body' => 'identical body', + ]); + + livewire(CommentList::class, [ + 'record' => $realPost, + 'paginate' => false, + ]) + ->assertSeeHtml('wire:key="' . $first->id . '"') + ->assertSeeHtml('wire:key="' . $second->id . '"'); +}); + test('CommentList can render both Comment and RenderableComment items', function () { /** @var User $user */ $user = User::factory()->create(); From bb5087f4bfcc7a16787cfbfd521c2f696772cc6e Mon Sep 17 00:00:00 2001 From: Malik Alleyne-Jones Date: Sat, 16 May 2026 15:44:28 -0400 Subject: [PATCH 2/3] Fix Livewire key collisions using class-qualified identifiers Include the class name in Livewire component keys to prevent collisions when Comment and RenderableComment instances share the same ID. Mark `getContentHash()` as deprecated since it's no longer used for key generation. --- resources/views/comment-list.blade.php | 2 +- src/Comment.php | 3 ++ src/Contracts/RenderableComment.php | 3 ++ src/RenderableComment.php | 3 ++ tests/Livewire/CommentListTest.php | 58 +++++++++++++++++++++++++- 5 files changed, 66 insertions(+), 3 deletions(-) diff --git a/resources/views/comment-list.blade.php b/resources/views/comment-list.blade.php index 5e6725b..76449b1 100644 --- a/resources/views/comment-list.blade.php +++ b/resources/views/comment-list.blade.php @@ -17,7 +17,7 @@ class="comm:w-8 comm:h-8 comm:text-gray-400 comm:dark:text-gray-500" @foreach ($this->comments as $comment) label; } + /** + * @deprecated No longer used internally for Livewire keys; will be removed in the next major version. + */ public function getContentHash(): string { return "comment-$this->id"; diff --git a/tests/Livewire/CommentListTest.php b/tests/Livewire/CommentListTest.php index 636d378..e691034 100644 --- a/tests/Livewire/CommentListTest.php +++ b/tests/Livewire/CommentListTest.php @@ -82,8 +82,31 @@ 'record' => $realPost, 'paginate' => false, ]) - ->assertSeeHtml('wire:key="' . $first->id . '"') - ->assertSeeHtml('wire:key="' . $second->id . '"'); + ->assertSeeHtml('wire:key="' . CommentModel::class . ':' . $first->id . '"') + ->assertSeeHtml('wire:key="' . CommentModel::class . ':' . $second->id . '"'); +}); + +test('CommentList keeps comment keys stable across edits', function () { + /** @var User $user */ + $user = User::factory()->create(); + /** @var Post $realPost */ + $realPost = Post::factory()->create(); + + /** @var CommentModel $comment */ + $comment = CommentModel::factory()->author($user)->commentable($realPost)->create([ + 'body' => 'original body', + ]); + + $key = 'wire:key="' . CommentModel::class . ':' . $comment->id . '"'; + + $component = livewire(CommentList::class, [ + 'record' => $realPost, + 'paginate' => false, + ])->assertSeeHtml($key); + + $comment->update(['body' => 'edited body']); + + $component->call('reloadComments')->assertSeeHtml($key); }); test('CommentList can render both Comment and RenderableComment items', function () { @@ -121,3 +144,34 @@ ->assertSee('System') ->assertSee('System message'); }); + +test('CommentList renders Comment and RenderableComment sharing an id without key collision', function () { + /** @var User $user */ + $user = User::factory()->create(); + /** @var Post $realPost */ + $realPost = Post::factory()->create(); + + /** @var CommentModel $comment */ + $comment = CommentModel::factory() + ->author($user) + ->commentable($realPost) + ->create([ + 'body' => 'Real comment body', + ]); + + // RenderableComment deliberately reuses the Comment's primary key. + $renderable = new RenderableComment(id: $comment->id, authorName: 'System', body: 'System message'); + + /** @var Post|Mockery\MockInterface $post */ + $post = Mockery::mock(Post::class)->makePartial(); + $post->shouldReceive('getComments') + ->once() + ->andReturn(collect([$comment, $renderable])); + + livewire(CommentList::class, [ + 'record' => $post, + 'paginate' => false, + ]) + ->assertSeeHtml('wire:key="' . CommentModel::class . ':' . $comment->id . '"') + ->assertSeeHtml('wire:key="' . RenderableComment::class . ':' . $renderable->getId() . '"'); +}); From 724e972db0508763d2cba99b58aebfad60366d28 Mon Sep 17 00:00:00 2001 From: Malik Alleyne-Jones Date: Sat, 30 May 2026 18:05:48 -0400 Subject: [PATCH 3/3] Update CommentListTest.php --- tests/Livewire/CommentListTest.php | 55 +++++++++++++++++++----------- 1 file changed, 36 insertions(+), 19 deletions(-) diff --git a/tests/Livewire/CommentListTest.php b/tests/Livewire/CommentListTest.php index e691034..e069a4a 100644 --- a/tests/Livewire/CommentListTest.php +++ b/tests/Livewire/CommentListTest.php @@ -3,14 +3,24 @@ use Kirschbaum\Commentions\Comment as CommentModel; use Kirschbaum\Commentions\Livewire\CommentList; use Kirschbaum\Commentions\RenderableComment; -use Mockery; +use Mockery\MockInterface; use Tests\Models\Post; use Tests\Models\User; use function Pest\Livewire\livewire; +function assertCommentKey($component, string $class, int|string $id): void +{ + $html = $component->html(); + + $literal = 'wire:key="' . $class . ':' . $id . '"'; + $snapshot = trim(json_encode("$class:$id"), '"'); + + expect(str_contains($html, $literal) || str_contains($html, $snapshot))->toBeTrue(); +} + test('CommentList calls getComments when not paginating', function () { - /** @var Post|Mockery\MockInterface $post */ + /** @var Post|MockInterface $post */ $post = Mockery::mock(Post::class)->makePartial(); $post->shouldReceive('getComments') @@ -26,7 +36,7 @@ }); test('CommentList calls getComments when paginating', function () { - /** @var Post|Mockery\MockInterface $post */ + /** @var Post|MockInterface $post */ $post = Mockery::mock(Post::class)->makePartial(); $post->shouldReceive('getComments') @@ -43,7 +53,7 @@ }); test('CommentList can render non-Comment renderable items', function () { - /** @var Post|Mockery\MockInterface $post */ + /** @var Post|MockInterface $post */ $post = Mockery::mock(Post::class)->makePartial(); $items = collect([ @@ -78,12 +88,13 @@ 'body' => 'identical body', ]); - livewire(CommentList::class, [ + $component = livewire(CommentList::class, [ 'record' => $realPost, 'paginate' => false, - ]) - ->assertSeeHtml('wire:key="' . CommentModel::class . ':' . $first->id . '"') - ->assertSeeHtml('wire:key="' . CommentModel::class . ':' . $second->id . '"'); + ]); + + assertCommentKey($component, CommentModel::class, $first->id); + assertCommentKey($component, CommentModel::class, $second->id); }); test('CommentList keeps comment keys stable across edits', function () { @@ -97,16 +108,21 @@ 'body' => 'original body', ]); - $key = 'wire:key="' . CommentModel::class . ':' . $comment->id . '"'; - - $component = livewire(CommentList::class, [ + $before = livewire(CommentList::class, [ 'record' => $realPost, 'paginate' => false, - ])->assertSeeHtml($key); + ]); + + assertCommentKey($before, CommentModel::class, $comment->id); $comment->update(['body' => 'edited body']); - $component->call('reloadComments')->assertSeeHtml($key); + $after = livewire(CommentList::class, [ + 'record' => $realPost, + 'paginate' => false, + ]); + + assertCommentKey($after, CommentModel::class, $comment->id); }); test('CommentList can render both Comment and RenderableComment items', function () { @@ -127,7 +143,7 @@ $items = collect([$comment, $renderable]); - /** @var Post|Mockery\MockInterface $post */ + /** @var Post|MockInterface $post */ $post = Mockery::mock(Post::class)->makePartial(); $post->shouldReceive('getComments') ->once() @@ -162,16 +178,17 @@ // RenderableComment deliberately reuses the Comment's primary key. $renderable = new RenderableComment(id: $comment->id, authorName: 'System', body: 'System message'); - /** @var Post|Mockery\MockInterface $post */ + /** @var Post|MockInterface $post */ $post = Mockery::mock(Post::class)->makePartial(); $post->shouldReceive('getComments') ->once() ->andReturn(collect([$comment, $renderable])); - livewire(CommentList::class, [ + $component = livewire(CommentList::class, [ 'record' => $post, 'paginate' => false, - ]) - ->assertSeeHtml('wire:key="' . CommentModel::class . ':' . $comment->id . '"') - ->assertSeeHtml('wire:key="' . RenderableComment::class . ':' . $renderable->getId() . '"'); + ]); + + assertCommentKey($component, CommentModel::class, $comment->id); + assertCommentKey($component, RenderableComment::class, $renderable->getId()); });