diff --git a/Classes/AssetCollector.php b/Classes/AssetCollector.php index 6bd83ac..95ebe82 100644 --- a/Classes/AssetCollector.php +++ b/Classes/AssetCollector.php @@ -118,6 +118,75 @@ public function getIconIdentifierFromFileName(string $xmlFile): string return str_replace('.svg', '', basename($xmlFile)); } + /** + * Make the inline SVG sprite self-healing: ensure every icon referenced in + * the given markup (via ) is collected for inline + * rendering. + * + * The sprite is injected into the response by a middleware reading the + * collected files from the "tx_assetcollector" cache, which lives next to – + * but separate from – the page cache. If that cache ever desyncs (e.g. it is + * cleared while the page cache survives, or it is stored on a different + * backend), a fully cached page is delivered without the ViewHelpers being + * re-rendered, the collector ends up empty and the sprite would be missing – + * every icon on the page disappears until the page cache is regenerated. + * + * By re-resolving the referenced icons from the given icon registry + * (identifier => SVG file) we guarantee the sprite is never delivered + * incomplete. The registry must be passed in because the TypoScript setup is + * not available in cached frontend scope – the caller is responsible for + * providing it (see InlineSvgInjector, which persists it in a dedicated + * cache). An empty registry makes this a no-op, so it can never make + * rendering worse than before. + * + * @param \Closure(): array $iconRegistryProvider lazily resolves + * the icon identifier => SVG file map; only invoked when the page actually + * references an icon that is not collected, to avoid needless cache reads + */ + public function addReferencedIcons(string $markup, \Closure $iconRegistryProvider): void + { + if ($markup === '' || !preg_match_all('/(?:xlink:href|href)="#icon-([A-Za-z0-9_.-]+)"/', $markup, $matches)) { + return; + } + $referencedIdentifiers = array_unique($matches[1]); + $collectedIdentifiers = []; + foreach ($this->getUniqueXmlFiles() as $xmlFile) { + $collectedIdentifiers[$this->getIconIdentifierFromFileName($xmlFile)] = true; + } + $missingIdentifiers = array_diff($referencedIdentifiers, array_keys($collectedIdentifiers)); + if ($missingIdentifiers === []) { + return; + } + $iconRegistry = $iconRegistryProvider(); + foreach ($missingIdentifiers as $identifier) { + if (isset($iconRegistry[$identifier])) { + $this->addXmlFile($iconRegistry[$identifier]); + } + } + } + + /** + * Map of icon identifier => SVG file path, built from the configured icon + * registry (plugin.tx_assetcollector.icons). Returns an empty array when the + * TypoScript setup is not available (e.g. in cached frontend scope). + * + * @return array + */ + public function getIconRegistry(): array + { + if ($this->typoScriptConfiguration === null) { + $this->loadTypoScript(); + } + $registry = []; + foreach ($this->typoScriptConfiguration as $file) { + $file = (string)$file; + if ($file !== '') { + $registry[$this->getIconIdentifierFromFileName($file)] = $file; + } + } + return $registry; + } + public function buildInlineCssTag(): string { $inlineCss = implode("\n", $this->getUniqueInlineCss()); @@ -255,9 +324,19 @@ protected function loadTypoScript(): void if ($request === null) { return; } - /** @var FrontendTypoScript $typoScript */ + /** @var FrontendTypoScript|null $typoScript */ $typoScript = $request->getAttribute('frontend.typoscript'); - $setup = $typoScript->getSetupArray(); + if ($typoScript === null) { + return; + } + // The full setup array is only available in uncached frontend scope. On a + // cached request getSetupArray() throws, so degrade gracefully to an empty + // configuration instead of breaking the request. + try { + $setup = $typoScript->getSetupArray(); + } catch (\RuntimeException) { + return; + } $this->typoScriptConfiguration = $setup['plugin.']['tx_assetcollector.']['icons.'] ?? []; } diff --git a/Classes/Middleware/InlineSvgInjector.php b/Classes/Middleware/InlineSvgInjector.php index 9d4090b..056cf16 100644 --- a/Classes/Middleware/InlineSvgInjector.php +++ b/Classes/Middleware/InlineSvgInjector.php @@ -31,7 +31,9 @@ class InlineSvgInjector implements MiddlewareInterface public function __construct( #[Autowire(service: 'cache.tx_assetcollector')] private readonly FrontendInterface $cache, - private readonly AssetCollector $assetCollector + private readonly AssetCollector $assetCollector, + #[Autowire(service: 'cache.tx_assetcollector_registry')] + private readonly FrontendInterface $registryCache ) { } @@ -41,11 +43,11 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface if ($response instanceof NullResponse) { return $response; } - $svgAsset = $this->getInlineSvgAsset($request); + $body = $response->getBody(); + $body->rewind(); + $contents = $body->getContents(); + $svgAsset = $this->getInlineSvgAsset($request, $contents); if ($svgAsset !== '') { - $body = $response->getBody(); - $body->rewind(); - $contents = $response->getBody()->getContents(); if (str_contains($contents, '')) { $content = str_ireplace( '', @@ -62,7 +64,7 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface return $response; } - protected function getInlineSvgAsset(ServerRequestInterface $request): string + protected function getInlineSvgAsset(ServerRequestInterface $request, string $responseBody): string { /** @var CacheDataCollector $cacheDataCollector */ $cacheDataCollector = $request->getAttribute('frontend.cache.collector'); @@ -74,6 +76,30 @@ protected function getInlineSvgAsset(ServerRequestInterface $request): string if (!empty($cached['xmlFiles'] ?? null) && is_array($cached['xmlFiles'])) { $this->assetCollector->mergeXmlFiles($cached['xmlFiles']); } + // Self-heal: re-resolve any icon referenced in the page that is not (or + // no longer) collected, so a desynced "tx_assetcollector" cache can never + // result in a page being delivered with a missing/incomplete SVG sprite. + $cacheIdentifier = 'icons-' . ($request->getAttribute('site')?->getIdentifier() ?? 'default'); + // In uncached scope the TypoScript-backed registry is available – (re)persist + // it so it can be used as a fallback for cached requests, where TypoScript + // (and therefore the registry) is no longer available. + $liveRegistry = $this->assetCollector->getIconRegistry(); + if ($liveRegistry !== []) { + $this->registryCache->set($cacheIdentifier, $liveRegistry); + } + $this->assetCollector->addReferencedIcons( + $responseBody, + fn (): array => $liveRegistry !== [] ? $liveRegistry : $this->readPersistedRegistry($cacheIdentifier) + ); return $this->assetCollector->buildInlineXmlTag(); } + + /** + * @return array + */ + private function readPersistedRegistry(string $cacheIdentifier): array + { + $persisted = $this->registryCache->has($cacheIdentifier) ? $this->registryCache->get($cacheIdentifier) : null; + return is_array($persisted) ? $persisted : []; + } } diff --git a/Configuration/Services.yaml b/Configuration/Services.yaml index a1d9d79..722304d 100644 --- a/Configuration/Services.yaml +++ b/Configuration/Services.yaml @@ -12,3 +12,8 @@ services: factory: ['@TYPO3\CMS\Core\Cache\CacheManager', 'getCache'] arguments: ['tx_assetcollector'] + cache.tx_assetcollector_registry: + class: TYPO3\CMS\Core\Cache\Frontend\FrontendInterface + factory: ['@TYPO3\CMS\Core\Cache\CacheManager', 'getCache'] + arguments: ['tx_assetcollector_registry'] + diff --git a/Tests/Functional/Frontend/SvgViewHelperSelfHealTest.php b/Tests/Functional/Frontend/SvgViewHelperSelfHealTest.php new file mode 100644 index 0000000..1adbd26 --- /dev/null +++ b/Tests/Functional/Frontend/SvgViewHelperSelfHealTest.php @@ -0,0 +1,71 @@ + 'typo3conf/sites']; + + protected array $configurationToUseInTestInstance = [ + 'SYS' => [ + 'caching' => [ + 'cacheConfigurations' => [ + 'pages' => [ + 'backend' => Typo3DatabaseBackend::class, + ], + 'tx_assetcollector' => [ + 'backend' => Typo3DatabaseBackend::class, + ], + 'tx_assetcollector_registry' => [ + 'backend' => Typo3DatabaseBackend::class, + ], + ], + ], + ], + ]; + + /** + * Reproduces the situation where the page cache survives but the + * "tx_assetcollector" cache that holds the collected SVG files is gone (e.g. + * desynced because it sits on a different backend, or was cleared on its + * own). The cached page must still be delivered with the full inline SVG + * sprite instead of silently losing all its icons. + */ + #[Test] + public function inlineSvgSpriteIsRebuiltForCachedPageWhenAssetCacheIsLost(): void + { + $this->importCSVDataSet(__DIR__ . '/Fixtures/SvgViewHelper.csv'); + + // 1. uncached request: the sprite is rendered and the icon registry gets persisted + $bodyUncached = (string)$this->executeFrontendSubRequest(new InternalRequest('http://localhost/'))->getBody(); + self::assertStringContainsString('getCache('tx_assetcollector')->flush(); + + // 3. cached request: the sprite must be rebuilt from the persisted registry + $bodyCached = (string)$this->executeFrontendSubRequest(new InternalRequest('http://localhost/'))->getBody(); + self::assertStringContainsString('buildInlineCssTag(); self::assertStringContainsString('', + static fn (): array => ['Extension' => 'EXT:assetcollector/Resources/Public/Icons/Extension.svg'] + ); + self::assertSame( + ['EXT:assetcollector/Resources/Public/Icons/Extension.svg'], + $assetCollector->getUniqueXmlFiles() + ); + } + + #[Test] + public function addReferencedIconsDoesNotInvokeRegistryWhenIconAlreadyCollected(): void + { + $assetCollector = new AssetCollector(); + $assetCollector->addXmlFile('EXT:assetcollector/Resources/Public/Icons/Extension.svg'); + $registryWasResolved = false; + $assetCollector->addReferencedIcons( + '', + static function () use (&$registryWasResolved): array { + $registryWasResolved = true; + return []; + } + ); + self::assertFalse($registryWasResolved, 'The registry provider must only be invoked when an icon is missing.'); + self::assertSame( + ['EXT:assetcollector/Resources/Public/Icons/Extension.svg'], + $assetCollector->getUniqueXmlFiles() + ); + } + + #[Test] + public function addReferencedIconsIgnoresIconsThatAreNotInTheRegistry(): void + { + $assetCollector = new AssetCollector(); + $assetCollector->addReferencedIcons( + '', + static fn (): array => ['Extension' => 'EXT:assetcollector/Resources/Public/Icons/Extension.svg'] + ); + self::assertSame([], $assetCollector->getUniqueXmlFiles()); + } } diff --git a/ext_localconf.php b/ext_localconf.php index c08f4f6..f318a50 100644 --- a/ext_localconf.php +++ b/ext_localconf.php @@ -10,3 +10,12 @@ if (!is_array($GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations']['tx_assetcollector'] ?? null)) { $GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations']['tx_assetcollector'] = ['groups' => ['pages']]; } + +// Holds the icon registry (identifier => SVG file) so the inline SVG sprite can +// be rebuilt for cached pages even when the page-cache-bound "tx_assetcollector" +// cache is gone. Lives in the "system" group on purpose: it must survive a +// frontend ("pages") cache flush, otherwise it could vanish together with the +// very entry it is meant to recover from. +if (!is_array($GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations']['tx_assetcollector_registry'] ?? null)) { + $GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations']['tx_assetcollector_registry'] = ['groups' => ['system']]; +}