ux icon indicating copy to clipboard operation
ux copied to clipboard

[UX-Icons] Allow Reuse

Open Jibbarth opened this issue 1 year ago • 8 comments

When an SVG has been added once in a page, it can be reused with the <use xlink:href=#id> later in the same page to avoid duplication of svg.

It could be great if ux_icon would have a allowReuse parameters to transform icon to use a previously added.

For example :

    {{ ux_icon('fa6-brands:symfony', {}, true)}}
    {{ ux_icon('fa6-brands:symfony', {'style':'color:red'}, true)}}

Would produce following html

<div class="example-wrapper">
    <svg viewBox="0 0 512 512" fill="currentColor" aria-hidden="true" id="fa6-brands--symfony"><path fill="currentColor" d="M256 8C119 8 8 119 8 256s111 248 248 248s248-111 248-248S393 8 256 8m133.74 143.54c-11.47.41-19.4-6.45-19.77-16.87c-.27-9.18 6.68-13.44 6.53-18.85c-.23-6.55-10.16-6.82-12.87-6.67c-39.78 1.29-48.59 57-58.89 113.85c21.43 3.15 36.65-.72 45.14-6.22c12-7.75-3.34-15.72-1.42-24.56c4-18.16 32.55-19 32 5.3c-.36 17.86-25.92 41.81-77.6 35.7c-10.76 59.52-18.35 115-58.2 161.72c-29 34.46-58.4 39.82-71.58 40.26c-24.65.85-41-12.31-41.58-29.84c-.56-17 14.45-26.26 24.31-26.59c21.89-.75 30.12 25.67 14.88 34c-12.09 9.71.11 12.61 2.05 12.55c10.42-.36 17.34-5.51 22.18-9c24-20 33.24-54.86 45.35-118.35c8.19-49.66 17-78 18.23-82c-16.93-12.75-27.08-28.55-49.85-34.72c-15.61-4.23-25.12-.63-31.81 7.83c-7.92 10-5.29 23 2.37 30.7l12.63 14c15.51 17.93 24 31.87 20.8 50.62c-5.06 29.93-40.72 52.9-82.88 39.94c-36-11.11-42.7-36.56-38.38-50.62c7.51-24.15 42.36-11.72 34.62 13.6c-2.79 8.6-4.92 8.68-6.28 13.07c-4.56 14.77 41.85 28.4 51-1.39c4.47-14.52-5.3-21.71-22.25-39.85c-28.47-31.75-16-65.49 2.95-79.67C204.23 140.13 251.94 197 262 205.29c37.17-109 100.53-105.46 102.43-105.53c25.16-.81 44.19 10.59 44.83 28.65c.25 7.69-4.17 22.59-19.52 23.13"></path></svg>
    <svg viewBox="0 0 512 512" fill="currentColor" aria-hidden="true" style="color:red"><use xlink:href="#fa6-brands--symfony"></use></svg>
</div>

image

I was able to achieve this by adding following line in IconRenderer

final class IconRenderer
{
+    private array $renderedIcons = [];

    public function renderIcon(string $name, array $attributes = [], bool $allowReuse = false): string
    {
        $icon = $this->registry->get($name)
            ->withAttributes($this->getIconAttributes($name, $attributes));
        
+        if ($allowReuse) {
+            $id = $icon::nameToId($name);
+            if (array_key_exists($id, $this->renderedIcons)) {
+                $icon = new Icon('<use xlink:href="#'.$id.'"/>', $icon->getAttributes());
+            } else {
+                $icon = $icon->withAttributes(['id' => $id]);
+                $this->renderedIcons[$id] = true; 
+            }
+        }

        return $icon->toHtml();
    }

For really complexe svg reused multiple time, it could save the size of the generated page.

WDYT ? If you think such feature would be nice, I could provide a PR.

Jibbarth avatar Apr 29 '24 11:04 Jibbarth

It is something we have in mind (@kbond even prototyped something as first), but this is much more complex than it seems

Webpages are very often not rendered in a linear / complete manner (think cache, ESI, components, duplicate ids, ...)

smnandre avatar Apr 29 '24 12:04 smnandre

Yes, this is the deferred idea I originally had in ux-icons. Blade icons has a similar feature.

Simon and I are planning some refactoring so I'd like to hold off on this until this is done. We'll keep it in mind though as I think it's required.

kbond avatar Apr 30 '24 15:04 kbond

For the record, I was able to implement it on my personal project by adding a decorator on top of the IconRegistry, instead of the IconRenderer.

I share it below if anyone need this, but remember it may not work in all cases as mentionned by @smnandre, and we shouldn't decorate internal services :warning:

View code
<?php

declare(strict_types=1);

namespace App\Icon;

use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\UX\Icons\Icon;
use Symfony\UX\Icons\IconRegistryInterface;

#[AsDecorator('.ux_icons.icon_registry')]
final class IconAlreadyUsedRegistry implements IconRegistryInterface
{
    /**
     * @var array<string, string>
     */
    private array $alreadyUsed = [];

    public function __construct(
        private RequestStack $requestStack,
        private IconRegistryInterface $decorated,
    ) {}

    public function get(string $name): Icon
    {
        $icon = $this->decorated->get($name);

        $id = $icon::nameToId($name);
        // Generate an unique Id for each icon by Request
        // Needed as static-site does not restart kernel on each request,
        // and icon are considered as already used on new pages
        $request = $this->requestStack->getCurrentRequest();
        if (null === $request) {
            return $icon;
        }
        $id = md5(sprintf('%s-%s', $id, spl_object_id($request)));
        if (!\array_key_exists($id, $this->alreadyUsed)) {
            $this->alreadyUsed[$id] = $id;

            return $icon->withAttributes(['id' => $id]);
        }

        // Return a reusable icon, take only viewBox attribute to avoid rendering issues
        return new Icon('<use xlink:href="#' . $id . '"/>', [$icon->getAttributes()['viewBox']]);
    }
}

Fully implemented here

Jibbarth avatar May 04 '24 16:05 Jibbarth

We are working on the configuration of icons (default attributes, prefix aliases). Once it's done we will be able to implement this feature (step by step) :)


I think the separation between the symbols (what's gonna be reused) and the rendered icons (using those symbols) should be stronger... Or we may have troubles with default attributes, runtime render ones, etc.

So the final HTML should probably be more like

 <svg style="display: none;" hidden>
    <symbol id="icon:lucide:heart" viewBox="0 0 512 512"> ... </symbol>
</svg>

<svg viewBox="0 0 512 512"><use href="#icon:lucide:heart"></use></svg>
<svg viewBox="0 0 512 512" style="color:red"><use href="#icon:lucide:heart"></use></svg>

Two comments here:

  • href must be employed there, as xlink:href is deprecated
  • i'm using custom syntax for id here, that would be to decide, but we must avoid id collisions (with another render, but also with any userland ones... so there is some thinking to do and non-used syntaxes/characters may be a way out)

The other complexity lies in those two questions:

Can we ensure a symbol has been renderered in the DOM before we render an icon using it Can we ensure a symbol has not been rendered when we render it

I'm not saying we must find a single silver-bullet answer. And i'm convinved there will be some choices about the DX / performances / predictability to me made there. But they are the one to keep in mind :)

smnandre avatar May 05 '24 20:05 smnandre

So i worked a bit on this tonight, to verify using svg symbols has a real impact on page weight / load / etc.

Scenario

I made, on a pretty blank page, a list of 50 rows, each of them having these icons:

'lucide:circle-play',
'lucide:circle-pause',
'solar:eye-outline',
'fluent:share-48-regular',
'arcticons:settings',
'grommet-icons:validate',

Two versions

Then i made two runs of tests (loading / DOM size / network / etc)

  • the first one with the current ux_icon() function (so icons rendered fully in svg)
  • the second one with a custom implementation (more on that later) using svg symbols

And i must admit i did not anticipate such a difference: more than 50% of page size reduction (around 280ko vs 130ko ... and i put texte and styles in each row so really just the innerSVG made this).

And the <svg><use></svg> were nothing but optimized, as they all had the same attributes, viewbox, styles... that could be in one ligne of CSS... but this is another topic for another day :))

{# Before #} 

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="currentColor" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" d="M39.23 26a17 17 0 0 0 .14-2a17 17 0 0 0-.14-2l4.33-3.39a1 1 0 0 0 .25-1.31l-4.1-7.11a1 1 0 0 0-1.25-.44l-5.11 2.06a15.7 15.7 0 0 0-3.46-2l-.77-5.43a1 1 0 0 0-1-.86H19.9a1 1 0 0 0-1 .86l-.77 5.43a15.4 15.4 0 0 0-3.46 2L9.54 9.75a1 1 0 0 0-1.25.44l-4.1 7.11a1 1 0 0 0 .25 1.31L8.76 22a17 17 0 0 0-.14 2a17 17 0 0 0 .14 2l-4.32 3.39a1 1 0 0 0-.25 1.31l4.1 7.11a1 1 0 0 0 1.25.44l5.11-2.06a15.7 15.7 0 0 0 3.46 2l.77 5.43a1 1 0 0 0 1 .86h8.2a1 1 0 0 0 1-.86l.77-5.43a15.4 15.4 0 0 0 3.46-2l5.11 2.06a1 1 0 0 0 1.25-.44l4.1-7.11a1 1 0 0 0-.25-1.31ZM24 31.18A7.18 7.18 0 1 1 31.17 24A7.17 7.17 0 0 1 24 31.18"></path></svg>

{# After #} 
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="currentColor" foo="" aria-hidden="true"><use xlink:href="" href="#arcticons:settings"></use></svg>

Problematics

So as i said in June, i'm still not convinced we should offer too much "magic" at first... because i'm not sure we can deliver ... .. includes / embed / other Twig fun stuff... cache ... components with context isolation, .. ajax, partials renders, Turbo streams, weird extensions, there is so many scenarios were we cannot garantee anything outside the current template

and i'm a bit afraid of the frustration it could generate :|

But i'm very open to have another vision :)

Next?

To move on, in a first step, i would suggest the following: we offer 2 new functions (or arguments / attribute / this is up to debate)

  • Write sprite (when i say "sprite" i mean : a set of symbols in HTML with a display none
  • Write icon using reference instead of SVG

I'm not sure of the DX but what i see here is that the main difference will be just around loops (like my 50 "admin-like" rows).. and this can already have a good impact.

And from there we see were we go ?

Implementation

For now i "just" had to create ux_icon_sprite( names ) that pre-render the icons before the table, and find a way to generate <use... > instead of the innerSVG, so i used "foo=true" in attribute, but we could also use a "defer" or "symbol" or even using another param name than .. "name"

<twig:ux:icon ref="" /> 

or 

<twig:ux:icon use="" /> 

And, in a simplistic manner, this is pretty much the all the "internal" work

    public function renderSprite(array $names): string
    {
        $symbols = '';
        foreach ($names as $name) {
            $symbols .= $this->renderSymbol($name);
        }

        return '<svg xmlns="http://www.w3.org/2000/svg" style="display: none">'.$symbols.'</svg>';
    }

    public function renderUse($name, Icon $icon): string
    {
        $viewBox = $icon->getAttributes()['viewBox'] ?? '0 0 24 24';

        return '<use xlink:href href="#'.$name.'" />'; //viewBox="'.$viewBox.'" />';
    }

    public function renderSymbol(string $name): string
    {
        $icon = $this->registry->get($name);
        $viewBox = $icon->getAttributes()['viewBox'] ?? '0 0 24 24';

        return '<symbol id="'.$name.'" viewBox="'.$viewBox.'">'.$icon->getInnerSvg().'</symbol>';
    }

So what do you think ? Can we start with a very small subset of wanted features, or do you think we should have "more" to offer when we release this ?

smnandre avatar Sep 04 '24 00:09 smnandre

My 2 cents on this topic, because I had to deal with SVG icons and sprites in my previous job.

At first, we used a static SVG sprite, and it was totally inefficient due to the number of icons it was made of. The DOM was insanely large, and it reduced front-end performance. Additionally, we couldn't take advantage of HTTP cache either, as the sprite was always the same and always inlined in the page.

We weren't happy with this sprite, so I migrated to a solution that didn't use a sprite or inlined SVGs, but instead used <img src="...svg">, rendered by a homemade Icon component. This way, we could easily reuse icons, the DOM was lighter, and we benefited from HTTP cache. We could use the icon once or 100 times on the same page with no worries—it's essentially free! However, with this solution, we couldn't use attributes like fill="currentColor", which was a requirement with our new graphic charter.

So, in our homemade Icon component, I made it possible for us to use inline SVGs when necessary. This way, we could get customization when needed (but without HTTP cache), or continue using <img src="...svg"> (with HTTP cache).

Of course, all of this doesn't help in scenarios where you have multiple instances of an icon that need to be customized with fill or other attributes.

What do you think?

Kocal avatar Sep 06 '24 07:09 Kocal

@Kocal i had similar experiences but sometimes different conclusions :)

I think the idea here is to focus on your last point (my "table/grid of things with icons every row" example) In fact with use these icons are inlined, we just avoid writing the full code everytime. So best of two worlds :)

I have things in mind for sprites but not with 500 icons clearly. But we could inject them in the Dom via a preloaded SVG file and use them as symbols.

Also, there is something about applying styles on icons and serve them as assets that we can work on later, it could be a great DX to offer and fun to use.


All this said, I'm suggesting... (wait for the surprise) that we move step by step and focus on precise use case ;) WDYT ?

smnandre avatar Sep 08 '24 21:09 smnandre

In fact with use these icons are inlined, we just avoid writing the full code everytime. So best of two worlds :)

I'm not sure to understand what you mean here, do you speak about writing <twig:UX:Icon name="..." /> or the generated output?

Kocal avatar Sep 11 '24 13:09 Kocal

Thank you for this suggestion. There has not been a lot of activity here for a while. Would you still like to see this feature? Every feature is developed by the community. Perhaps someone would like to try? You can read how to contribute to get started.

carsonbot avatar Mar 12 '25 12:03 carsonbot

Friendly ping? Should this still be open? I will close if I don't hear anything.

carsonbot avatar Mar 26 '25 12:03 carsonbot

Let's close from now, feel free to reopen if needed

Jibbarth avatar Mar 28 '25 16:03 Jibbarth

Just saying: I'd be very interesting in this feature.

E.g. we're updating the Symfony packages page. I want to display 5 icons per package. There are 266 packages, so the final page will have 5 * 266 = 1,330 SVG files 😢 With this feature it'd only have 5 SVG files.

But I only know "my own bubble". Maybe this is a niche edge-case that almost nobody experience in their apps. Thanks.

javiereguiluz avatar Jun 23 '25 13:06 javiereguiluz

TIL about https://symfony.com/packages 🤓

For the moment, even if that's not the primary goal of UX Icons, I can suggest you to use .svg files as external files, and load them <img src="...">. You will lose some customization (e.g.: fill="currentColor", but you can still duplicate the icon and customize it to your needs).

To use SVG sprite with UX Icons, we can get some inspirations from https://github.com/symfony/ux/issues/1800#issuecomment-2327660534. We could also imagine a solution more "manual", where you will first call a function (or twig component) to define your UX Icons sprites, and then tweak ux_icon() or <twig:ux:icon> to say "hey, use the icon from the sprite!"

Kocal avatar Jun 23 '25 13:06 Kocal

Thanks. For now I'm going to do it manually. Yes, if doing this partially explicit helps implement it, go for it. Thinking out loud, we could even just define a couple of new options:

{# this renders the SVG contents with "display: none" and the given "id" #}
{{ ux_icon('tabler:github', as_sprite = 'icon-cache-github') }}

{% foreach i in 1..266 %}
    {# ... #}

    {# render the same SVG defined previously as a reusable sprite #}
    {{ ux_icon('tabler:github', from_sprite = 'icon-cache-github') }}
{% endforeach %}

javiereguiluz avatar Jun 23 '25 13:06 javiereguiluz

That's a nice approach too

Kocal avatar Jun 23 '25 13:06 Kocal

@javiereguiluz what about something like: https://github.com/symfony/ux/issues/2849?

I can't find the thread but Simon did do a benchmark and, assuming using http compression, even 100's of the same icon inlined didn't cause any performance issues.

But, this keeps coming up so maybe this is something that should be revisited? Like I said above, I originally did have a deferred option + a response event listener that added the sprites. I dropped to keep the initial release simple and because of the lack of performance boost mentioned above.

kbond avatar Jun 23 '25 15:06 kbond

Yes, thanks to HTTP compression, this is not a problem for transmitted HTML size.

However, in cases like mine, there's a problem with resulting HTML size and memory/CPU needed to process and render it.

E.g. in my HTML page I have this repeated 266 times:

<svg viewBox="0 0 24 24" height="16" width="16" fill="currentColor" aria-hidden="true" class="ui-text-muted me-1"><use xlink:href="#icon-cache-github"></use></svg>

Without <use>, I'd have many more DOM nodes in the page that must be processed and rendered.


In any case, before doing anything, we should find out if this is truly a common enough feature for Symfony developers. It probably isn't.

javiereguiluz avatar Jun 23 '25 15:06 javiereguiluz

However, in cases like mine, there's a problem with resulting HTML size and memory/CPU needed to process and render it.

Got it, that makes sens.

we should find out if this is truly a common enough feature for Symfony developers.

It has come up quite a bit... #2849 isn't meant as a solution for this but "publishing" icons to public allows for linking them in <img> tags.

Thinking out loud, we could even just define a couple of new options:

That works... but if we go with sprite support, I think we should go all in:

  1. <ux:icon name="tabler:github" defer />
  2. Response listener builds/adds the sprites to the html

Maybe we could make the event listener dynamic? Only add if a deferred icon is used.

kbond avatar Jun 23 '25 15:06 kbond

.g. in my HTML page I have this repeated 266 times:

<svg viewBox="0 0 24 24" height="16" width="16" fill="currentColor" aria-hidden="true" class="ui-text-muted me-1"><use xlink:href="#icon-cache-github"></use></svg>

Without <use>, I'd have many more DOM nodes in the page that must be processed and rendered.

Well.... i made another benchmark I did not talk much here (i showed @Kocal the results iirc) and I was more than surprised to observe 100 icons fully inlined in SVG are a lot lot lighter in memory / CPU than references. Like, a lot.

Browsers are, as we often say, much much more "optimized" than we think (or used to observe to be fair), and for 100 icons in a page I still really would not bother doing anything.

Here, i'm pretty sure 10x the same inlined svg is exactly the same thing as 10x an external SVG = not much more than 1x the icon.

Where i'm less and less inclined to offer "a magic solution for everyone" is that..... there is no "magic solution for everyone", and every user that has specific performance problem with its icons it already

  • either looking on the wrong side of things
  • someone having more knowledge in brower/network/rendering performance that 99% of UX Icons users will ever need to be

And as I realized with my inline VS use experiment, some features that can look "good" for some use case can be very detrimental for others.

I don't see Symfony offering a lot of things to deal with "batches" for instance (except in messenger, and even there it's on a à-la-carte mode, because again there is no absolute solution for very specific issues).

But again, very open to discuss this topic, test solutions, etc.

smnandre avatar Jun 25 '25 03:06 smnandre

That being said, I'm still genuinely curious: in a mobile-first, performance-first era, what functional need truly justifies rendering 100 items - whatever those items may be - on a single page load?

I sincerely believe this is the right line of questioning here (at least for 99% of users raising concerns about icon performance).

smnandre avatar Jun 25 '25 04:06 smnandre

@smnandre I'm intrigued by this. Can you please tell me how did you do the benchmark so I can try to make my own and see how it goes?

About "mobile-first era" ... that's true for some websites but others (like symfony.com) will be forever "desktop first" because of its nature.

javiereguiluz avatar Jun 25 '25 06:06 javiereguiluz

By the way, the updated Symfony packages page is now live (https://symfony.com/packages). This is the page that uses the trick to reuse 5 SVG icons instead of including 1,330 icons in the page.

javiereguiluz avatar Jun 25 '25 07:06 javiereguiluz

@smnandre I'm intrigued by this. Can you please tell me how did you do the benchmark so I can try to make my own and see how it goes?

I think I still have that file on the giant mess that is my disk, diggin this week-end! 👨‍🚀

For the desktop-first I agree with you.. but then the performance pressure is -- often -- softer.

My overall point is not to say we should not do anything (and I already suggested things / tried code / etc about this topic, so really no "opposition per principle" here).. but I slowly become pretty convinced the situations and solutions are too specific on each case to offer something that would benefit everyone.

Again, i'd be happy to be proven otherwise :)

smnandre avatar Jun 26 '25 06:06 smnandre

By the way, the updated Symfony packages page is now live (https://symfony.com/packages). This is the page that uses the trick to reuse 5 SVG icons instead of including 1,330 icons in the page.

I did not see the new page yet, nice!

smnandre avatar Jun 26 '25 06:06 smnandre

If anyone is interested, I just published a blog post with all I learned while working on reusing SVG icons in the Symfony Packages redesign:

https://dev.to/javiereguiluz/reusing-svg-icons-for-faster-pages-62o

javiereguiluz avatar Jun 30 '25 08:06 javiereguiluz