From a4a2385cc939a8ced4353f8997a532c9fee4bae8 Mon Sep 17 00:00:00 2001 From: Nicolas Rigaud Date: Sat, 26 Oct 2024 10:04:10 +0200 Subject: [PATCH] Fix embedded Live Component rendering (#40) --- CONTRIBUTING.md | 2 +- .../components/LiveComponent.stories.js | 18 +++++++--- src/Controller/StorybookController.php | 11 ++++-- src/EventListener/ProxyRequestListener.php | 4 +++ src/Story.php | 6 ++++ src/Twig/TwigComponentSubscriber.php | 35 ++++++++++++++++++- src/Util/StorybookAttributes.php | 16 +++++++-- tests/Integration/StoryRendererTest.php | 12 +++---- tests/Unit/StoryRendererTest.php | 2 ++ tests/Unit/Util/StorybookAttributesTest.php | 34 +++++++++++++++--- 10 files changed, 118 insertions(+), 22 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 186c663..77643a0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -125,7 +125,7 @@ copy. Then run the tests with: ```shell -./scripts/test-app.sh +./scripts/test-sandbox.sh ``` #### Updating template stories diff --git a/sandbox/templates/components/LiveComponent.stories.js b/sandbox/templates/components/LiveComponent.stories.js index 71a7816..d743524 100644 --- a/sandbox/templates/components/LiveComponent.stories.js +++ b/sandbox/templates/components/LiveComponent.stories.js @@ -1,11 +1,9 @@ import LiveComponent from './LiveComponent.html.twig'; import { userEvent, expect, fn, waitFor, within } from '@storybook/test'; +import {twig} from "@sensiolabs/storybook-symfony-webpack5"; export default { - component: LiveComponent -} - -export const Default = { + component: LiveComponent, args: { onClick: fn() }, @@ -22,3 +20,15 @@ export const Default = { await waitFor(() => expect(args.onClick).toHaveBeenCalledOnce()); } } + +export const Default = { +} + +export const EmbeddedRender = { + render: () => ({ + components: {LiveComponent}, + template: twig` + + ` + }), +} diff --git a/src/Controller/StorybookController.php b/src/Controller/StorybookController.php index 37ae987..cbcdabc 100644 --- a/src/Controller/StorybookController.php +++ b/src/Controller/StorybookController.php @@ -23,17 +23,22 @@ public function __construct( public function __invoke(Request $request, string $story): Response { - $request = RequestAttributesHelper::withStorybookAttributes($request, ['story' => $story]); - $templateString = $request->getPayload()->get('template'); if (null === $templateString) { throw new BadRequestHttpException('Missing "template" in request body.'); } + $templateName = \sprintf('%s.html.twig', hash('xxh128', $templateString)); + + $request = RequestAttributesHelper::withStorybookAttributes($request, [ + 'story' => $story, + 'template' => $templateName, + ]); + $args = $this->argsProcessor->process($request); - $storyObj = new Story($story, $templateString, $args); + $storyObj = new Story($story, $templateName, $templateString, $args); $content = $this->storyRenderer->render($storyObj); diff --git a/src/EventListener/ProxyRequestListener.php b/src/EventListener/ProxyRequestListener.php index 2615ee9..0ba554a 100644 --- a/src/EventListener/ProxyRequestListener.php +++ b/src/EventListener/ProxyRequestListener.php @@ -31,6 +31,10 @@ public function onKernelRequest(RequestEvent $event): void { $request = $event->getRequest(); + if (!$event->isMainRequest() || 'storybook_render' === $request->attributes->get('_route')) { + return; + } + if (!RequestAttributesHelper::isProxyRequest($request) || !$request->headers->has('referer')) { return; } diff --git a/src/Story.php b/src/Story.php index 593961b..000dbc8 100644 --- a/src/Story.php +++ b/src/Story.php @@ -6,6 +6,7 @@ final class Story { public function __construct( private readonly string $id, + private readonly string $templateName, private readonly string $template, private readonly Args $args, ) { @@ -16,6 +17,11 @@ public function getId(): string return $this->id; } + public function getTemplateName(): string + { + return $this->templateName; + } + public function getTemplate(): string { return $this->template; diff --git a/src/Twig/TwigComponentSubscriber.php b/src/Twig/TwigComponentSubscriber.php index 28d6a11..6425659 100644 --- a/src/Twig/TwigComponentSubscriber.php +++ b/src/Twig/TwigComponentSubscriber.php @@ -8,6 +8,7 @@ use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\UX\TwigComponent\Event\PreRenderEvent; +use Symfony\UX\TwigComponent\MountedComponent; /** * @author Nicolas Rigaud @@ -25,10 +26,42 @@ public function __construct( public static function getSubscribedEvents(): array { return [ - PreRenderEvent::class => 'onPreRender', + PreRenderEvent::class => [ + ['inlineRootLiveComponent', 1], + ['onPreRender', 0], + ], ]; } + public function inlineRootLiveComponent(PreRenderEvent $event): void + { + $request = $this->requestStack->getMainRequest(); + + if (null === $request || !RequestAttributesHelper::isStorybookRequest($request)) { + return; + } + + if (!$event->getMetadata()->get('live', false)) { + // not a live component, skip + return; + } + + $storybookAttributes = RequestAttributesHelper::getStorybookAttributes($request); + + $mounted = $event->getMountedComponent(); + + if ($mounted->hasExtraMetadata('hostTemplate') && $mounted->getExtraMetadata('hostTemplate') === $storybookAttributes->template) { + // Dirty hack here: we are rendering a Live Component in the main story template with the embedded strategy. + // The host template actually doesn't exist, which will cause errors because Live Component will try to use + // it when re-rendering itself. As this is only useful for blocks resolution, we can safely remove this. + // Using reflection because no extension point is available here. + $refl = new \ReflectionProperty(MountedComponent::class, 'extraMetadata'); + $extraMetadata = $refl->getValue($mounted); + unset($extraMetadata['hostTemplate'], $extraMetadata['embeddedTemplateIndex']); + $refl->setValue($mounted, $extraMetadata); + } + } + public function onPreRender(PreRenderEvent $event): void { $request = $this->requestStack->getMainRequest(); diff --git a/src/Util/StorybookAttributes.php b/src/Util/StorybookAttributes.php index aa4f1f0..4ae9bc6 100644 --- a/src/Util/StorybookAttributes.php +++ b/src/Util/StorybookAttributes.php @@ -4,8 +4,13 @@ final class StorybookAttributes { + private const REQUIRED_ATTRIBUTES = [ + 'story', + ]; + public function __construct( public readonly string $story, + public readonly ?string $template = null, ) { } @@ -14,10 +19,15 @@ public function __construct( */ public static function from(array $attributes): self { - if (!isset($attributes['story'])) { - throw new \InvalidArgumentException('Missing key "story" in attributes.'); + foreach (self::REQUIRED_ATTRIBUTES as $attribute) { + if (!isset($attributes[$attribute])) { + throw new \InvalidArgumentException(\sprintf('Missing key "%s" in attributes.', $attribute)); + } } - return new self(story: $attributes['story']); + return new self( + story: $attributes['story'], + template: $attributes['template'] ?? null, + ); } } diff --git a/tests/Integration/StoryRendererTest.php b/tests/Integration/StoryRendererTest.php index 35ba71d..9eab3ef 100644 --- a/tests/Integration/StoryRendererTest.php +++ b/tests/Integration/StoryRendererTest.php @@ -17,7 +17,7 @@ public function testRenderStoryWithRestrictedContentThrowsException(string $temp self::bootKernel(); $renderer = static::getContainer()->get('storybook.story_renderer'); - $story = new Story('story', $template, new Args()); + $story = new Story('story', 'story.html.twig', $template, new Args()); $this->expectException(UnauthorizedStoryException::class); @@ -80,7 +80,7 @@ public function testRenderStoryWithAllowedContent(string $template, array $args self::bootKernel(); $renderer = static::getContainer()->get('storybook.story_renderer'); - $story = new Story('story', $template, new Args($args)); + $story = new Story('story', 'story.html.twig', $template, new Args($args)); $content = $renderer->render($story); @@ -169,7 +169,7 @@ public function testComponentUsingTrait() $renderer = static::getContainer()->get('storybook.story_renderer'); - $story = new Story('story', '', new Args()); + $story = new Story('story', 'story.html.twig', '', new Args()); $content = $renderer->render($story); @@ -187,8 +187,8 @@ public function testPassingPropsFromContextVariableWithSameName() $renderer = static::getContainer()->get('storybook.story_renderer'); - $storyWithFunction = new Story('story', '', new Args(['prop1' => 'foo'])); - $storyWithTag = new Story('story', '', new Args(['prop1' => 'foo'])); + $storyWithFunction = new Story('story', 'story.html.twig', '', new Args(['prop1' => 'foo'])); + $storyWithTag = new Story('story', 'story.html.twig', '', new Args(['prop1' => 'foo'])); $this->assertEquals($renderer->render($storyWithFunction), $renderer->render($storyWithTag)); } @@ -199,7 +199,7 @@ public function testComponentAttributeRendering() $renderer = static::getContainer()->get('storybook.story_renderer'); - $story = new Story('story', '', new Args()); + $story = new Story('story', 'story.html.twig', '', new Args()); $this->assertStringContainsString('foo="bar"', $renderer->render($story)); } diff --git a/tests/Unit/StoryRendererTest.php b/tests/Unit/StoryRendererTest.php index 32d459e..bc11db7 100644 --- a/tests/Unit/StoryRendererTest.php +++ b/tests/Unit/StoryRendererTest.php @@ -29,6 +29,7 @@ public function testRender() $story = new Story( 'story', + 'story.html.twig', '', new Args() ); @@ -51,6 +52,7 @@ public function testExceptions(Error $twigError, string $expectedException) $story = new Story( 'story', + 'story.html.twig', '', new Args() ); diff --git a/tests/Unit/Util/StorybookAttributesTest.php b/tests/Unit/Util/StorybookAttributesTest.php index 89c4df8..3f60f6f 100644 --- a/tests/Unit/Util/StorybookAttributesTest.php +++ b/tests/Unit/Util/StorybookAttributesTest.php @@ -7,12 +7,38 @@ class StorybookAttributesTest extends TestCase { - public function testCreateFromArray() + /** + * @dataProvider getValidArguments + */ + public function testCreateFromArray(array $array, StorybookAttributes $expected) { - $attributes = StorybookAttributes::from(['story' => 'story']); + $attributes = StorybookAttributes::from($array); - $this->assertInstanceOf(StorybookAttributes::class, $attributes); - $this->assertEquals('story', $attributes->story); + $this->assertEquals($expected, $attributes); + } + + public static function getValidArguments(): iterable + { + yield 'only story' => [ + 'array' => [ + 'story' => 'story', + ], + 'expected' => new StorybookAttributes( + 'story', + null + ), + ]; + + yield 'with template name' => [ + 'array' => [ + 'story' => 'story', + 'template' => 'story.html.twig', + ], + 'expected' => new StorybookAttributes( + 'story', + 'story.html.twig' + ), + ]; } public function testCreateFromInvalidArrayThrowsException()