Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/pre/quiet-images-call.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/enhanced-img': patch
---

fix: evaluate dynamic image source expressions once
1 change: 0 additions & 1 deletion packages/enhanced-img/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@
"dependencies": {
"magic-string": "^1.1.0",
"sharp": "^0.35.3",
"svelte-parse-markup": "^0.1.5",
"vite-imagetools": "^12.0.0",
"zimmerframe": "^1.1.4"
},
Expand Down
72 changes: 62 additions & 10 deletions packages/enhanced-img/src/vite-plugin.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
/** @import { Expression, Super } from 'estree' */
/** @import { AST } from 'svelte/compiler' */
import { existsSync } from 'node:fs';
import path from 'node:path';
import MagicString from 'magic-string';
import sharp from 'sharp';
import { parse } from 'svelte-parse-markup';
Comment thread
teemingc marked this conversation as resolved.
import { parse } from 'svelte/compiler';
import { walk } from 'zimmerframe';

// TODO: expose this in vite-imagetools rather than duplicating it
Expand Down Expand Up @@ -58,6 +59,23 @@ export function image_plugin(imagetools_plugin) {
* @type {Map<string, string>}
*/
const imports = new Map();
const identifiers = new Set();
let generated_name_index = 0;

walk(/** @type {any} */ (ast), null, {
_(node, { next }) {
if (node.type === 'Identifier') identifiers.add(node.name);
next();
}
});

function generate_name() {
while (true) {
const index = generated_name_index++;
const name = `__img${index ? `_${index}` : ''}`;
if (!identifiers.has(name)) return name;
}
}

/**
* @param {import('svelte/compiler').AST.RegularElement} node
Expand All @@ -67,20 +85,31 @@ export function image_plugin(imagetools_plugin) {
async function update_element(node, src_attribute) {
if (src_attribute.type === 'ExpressionTag') {
const start =
'end' in src_attribute.expression
? src_attribute.expression.end
: src_attribute.expression.range?.[0];
const end =
'start' in src_attribute.expression
? src_attribute.expression.start
: src_attribute.expression.range?.[0];
const end =
'end' in src_attribute.expression
? src_attribute.expression.end
: src_attribute.expression.range?.[1];

if (typeof start !== 'number' || typeof end !== 'number') {
throw new Error('ExpressionTag has no range');
}
const src_var_name = content.substring(start, end).trim();

s.update(node.start, node.end, dynamic_img_to_picture(content, node, src_var_name));
const src_expression = content.substring(start, end).trim();
const should_declare = !is_reference(src_attribute.expression);
const src_var_name = should_declare ? generate_name() : src_expression;

s.update(
node.start,
node.end,
dynamic_img_to_picture(
content,
node,
should_declare ? src_expression : undefined,
src_var_name
)
);
return;
}

Expand Down Expand Up @@ -311,6 +340,15 @@ function stringToNumber(param) {
return typeof param === 'string' ? parseInt(param) : param;
}

/**
* @param {Expression | Super} expression
*/
function is_reference(expression) {

@teemingc teemingc Aug 22, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if this needs to account for more expression types such as https://github.com/sveltejs/dts-buddy/blob/c1c53c6b93e5db189243540dcb193bf8cd2b0bd1/src/utils.js#L550

if (expression.type === 'Identifier') return true;
if (expression.type !== 'MemberExpression' || expression.computed) return false;
return is_reference(expression.object);
}

/**
* @param {string} content
* @param {import('svelte/compiler').AST.RegularElement} node
Expand Down Expand Up @@ -355,9 +393,10 @@ function to_value(src) {
* For images like `<img src={manually_imported} />`
* @param {string} content
* @param {import('svelte/compiler').AST.RegularElement} node
* @param {string | undefined} src_expression
* @param {string} src_var_name
*/
function dynamic_img_to_picture(content, node, src_var_name) {
function dynamic_img_to_picture(content, node, src_expression, src_var_name) {
const attributes = node.attributes;
/**
* @param attribute_name {string}
Expand All @@ -377,7 +416,7 @@ function dynamic_img_to_picture(content, node, src_var_name) {
attributes.splice(size_index, 1);
}

return `{#if typeof ${src_var_name} === 'string'}
const picture = `{#if typeof ${src_var_name} === 'string'}
{#if import.meta.env.DEV && ${!width_index && !height_index}}
{${src_var_name}} was not enhanced. Cannot determine dimensions.
{:else}
Expand All @@ -397,4 +436,17 @@ function dynamic_img_to_picture(content, node, src_var_name) {
})} />
</picture>
{/if}`;

// When the source is a computed expression we cache it in a variable to avoid evaluating it
// multiple times (e.g. calling a function once per template position). We use a reactive
// `{@const}` — wrapped in an `{#if true}` block so it's valid at this position — rather than a
// plain `{const}` declaration tag. Declaration tags cannot be used in legacy-mode components
// (they throw a compile error) and are only evaluated once, breaking reactivity when the
// expression depends on reactive state. `{@const}` is reactive, memoized, and works in both
// legacy and runes mode since Svelte 5.0.
if (src_expression) {
return `{#if true}{@const ${src_var_name} = ${src_expression}}\n${picture}\n{/if}`;
}

return picture;
}
10 changes: 10 additions & 0 deletions packages/enhanced-img/test/Input.svelte
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
<script lang="ts">
import manual_image1 from './no.png';
import manual_image2 from './no.svg';
import { default as __img } from './dev.png';

const src = manual_image1;
const images = [manual_image1, manual_image2];
const object = { image: manual_image1 };
const get_image = (image_key: number) => images[image_key];

let foo: string = 'bar';
Expand Down Expand Up @@ -53,6 +55,14 @@
<enhanced:img src={get_image(i)} alt="opt-in test" />
{/each}

{#each images as _, j}
<enhanced:img src={get_image(j)} alt="collision test" />
{/each}

<enhanced:img src={foo ? manual_image1 : manual_image2} alt="conditional test" />

<enhanced:img src={object.image} alt="member access test" />

<picture>
<source src="./dev.avif" />
<source srcset="./dev.avif 500v ./bar.avif 100v" />
Expand Down
79 changes: 69 additions & 10 deletions packages/enhanced-img/test/Output.svelte
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
<script lang="ts">

import manual_image1 from './no.png';

import manual_image2 from './no.svg';

import { default as __img } from './dev.png';

const src = manual_image1;
const images = [manual_image1, manual_image2];
const object = { image: manual_image1 };
const get_image = (image_key: number) => images[image_key];

let foo: string = 'bar';
Expand Down Expand Up @@ -38,7 +41,7 @@
<picture><source srcset="/1 1440w, /2 960w" type="image/avif" /><source srcset="/3 1440w, /4 960w" type="image/webp" /><source srcset="5 1440w, /6 960w" type="image/png" /><img src="/7" alt="absolute path test" width=1440 height=1440 /></picture>

{#if typeof src === 'string'}
{#if
{#if
import.meta.env.DEV && false}
{src} was not enhanced. Cannot determine dimensions.
{:else}
Expand All @@ -55,7 +58,7 @@

{#each images as image}
{#if typeof image === 'string'}
{#if
{#if
import.meta.env.DEV && false}
{image} was not enhanced. Cannot determine dimensions.
{:else}
Expand All @@ -72,23 +75,79 @@
{/each}

{#each images as _, i}
{#if typeof get_image(i) === 'string'}
{#if
{#if true}{@const __img_1 = get_image(i)}
{#if typeof __img_1 === 'string'}
{#if
import.meta.env.DEV && false}
{get_image(i)} was not enhanced. Cannot determine dimensions.
{__img_1} was not enhanced. Cannot determine dimensions.
{:else}
<img src={get_image(i)} alt="opt-in test" />
<img src={__img_1} alt="opt-in test" />
{/if}
{:else}
<picture>
{#each Object.entries(get_image(i).sources) as [format, srcset]}
{#each Object.entries(__img_1.sources) as [format, srcset]}
<source {srcset} type={'image/' + format} />
{/each}
<img src={get_image(i).img.src} alt="opt-in test" width={get_image(i).img.w} height={get_image(i).img.h} />
<img src={__img_1.img.src} alt="opt-in test" width={__img_1.img.w} height={__img_1.img.h} />
</picture>
{/if}
{/if}
{/each}

{#each images as _, j}
{#if true}{@const __img_2 = get_image(j)}
{#if typeof __img_2 === 'string'}
{#if
import.meta.env.DEV && false}
{__img_2} was not enhanced. Cannot determine dimensions.
{:else}
<img src={__img_2} alt="collision test" />
{/if}
{:else}
<picture>
{#each Object.entries(__img_2.sources) as [format, srcset]}
<source {srcset} type={'image/' + format} />
{/each}
<img src={__img_2.img.src} alt="collision test" width={__img_2.img.w} height={__img_2.img.h} />
</picture>
{/if}
{/if}
{/each}

{#if true}{@const __img_3 = foo ? manual_image1 : manual_image2}
{#if typeof __img_3 === 'string'}
{#if
import.meta.env.DEV && false}
{__img_3} was not enhanced. Cannot determine dimensions.
{:else}
<img src={__img_3} alt="conditional test" />
{/if}
{:else}
<picture>
{#each Object.entries(__img_3.sources) as [format, srcset]}
<source {srcset} type={'image/' + format} />
{/each}
<img src={__img_3.img.src} alt="conditional test" width={__img_3.img.w} height={__img_3.img.h} />
</picture>
{/if}
{/if}

{#if typeof object.image === 'string'}
{#if
import.meta.env.DEV && false}
{object.image} was not enhanced. Cannot determine dimensions.
{:else}
<img src={object.image} alt="member access test" />
{/if}
{:else}
<picture>
{#each Object.entries(object.image.sources) as [format, srcset]}
<source {srcset} type={'image/' + format} />
{/each}
<img src={object.image.img.src} alt="member access test" width={object.image.img.w} height={object.image.img.h} />
</picture>
{/if}

<picture>
<source src="./dev.avif" />
<source srcset="./dev.avif 500v ./bar.avif 100v" />
Expand Down
17 changes: 16 additions & 1 deletion packages/enhanced-img/test/markup-plugin.spec.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { compile } from 'svelte/compiler';
import { expect, it } from 'vitest';
import { image_plugin, parse_object } from '../src/vite-plugin.js';

Expand Down Expand Up @@ -39,9 +40,23 @@ it('Image preprocess snapshot test', async () => {
if (!transformed) throw new Error('transform unexpectedly returned no results');
if (typeof transformed === 'string') throw new Error('transform did not return a sourcemap');
if (!transformed.code) throw new Error('transform did not return any code');
const transformed_code = transformed.code.toString();

expect(transformed_code.match(/get_image\(i\)/g)).toHaveLength(1);
expect(transformed_code.match(/get_image\(j\)/g)).toHaveLength(1);
expect(transformed_code).toContain('{@const __img_1 = get_image(i)}');
expect(transformed_code).toContain('{@const __img_2 = get_image(j)}');
expect(transformed_code).toContain('{@const __img_3 = foo ? manual_image1 : manual_image2}');
expect(transformed_code).not.toMatch(/{@const __img(?:_\d+)? = src}/);
expect(transformed_code).not.toMatch(/{@const __img(?:_\d+)? = image}/);
expect(transformed_code).not.toMatch(/{@const __img(?:_\d+)? = object\.image}/);
expect(() => compile(transformed_code, { filename })).not.toThrow();

// Make imports readable
const ouput = transformed.code.toString().replace(/import/g, '\n\timport');
const ouput = transformed_code
.replace(/import/g, '\n\timport')
.replaceAll('{#if \n', '{#if\n')
.replace(/^[\t ]+$/gm, '');

await expect(ouput).toMatchFileSnapshot('./Output.svelte');
});
Expand Down
12 changes: 0 additions & 12 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading