Skip to content

Commit 9bda34b

Browse files
committed
fix(Image): close Image.Swap contract gaps from code review
- Broaden ImageSwap's transitionend handler to finalize done() on any property, not just opacity. Previously the previous layer leaked permanently if a consumer wrote a non-opacity transition on previous-class. Docs now call out that previous-class must include at least one transition for the exit to resolve. - Clarify in the docs that data-[has-previous]:opacity-100 on current-class is not optional — without it, the crossfade shows a background bleed-through during the 0→1 fade-in. Updated the Class routing bullet and added a dedicated bullet spelling out the pin as required. - Add a code comment in ImageSwap's currentSrc watcher explaining the cleared-then-restored-src edge case so a future reader sees the intentional trade-off. - Rename test describe block 'sSR' → 'SSR' (auto-capitalization tripped the Vitest reporter output).
1 parent ebe7e5d commit 9bda34b

2 files changed

Lines changed: 12 additions & 4 deletions

File tree

apps/docs/src/pages/components/semantic/image.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,8 +167,10 @@ Reach for this pattern whenever the user can navigate between preloaded sources
167167
A few details worth knowing:
168168

169169
- **Initial mount behaves like `Image.Img`** — there's no previous source on first load, so the component renders a single `<img>` and the placeholder shows while it loads. Only *subsequent* src changes engage the crossfade.
170-
- **Built on the Presence primitive** — the previous layer's mount lifecycle (mounted → leaving → unmounted) is managed by v0's `Presence` composable. CSS targets `data-state='leaving'` to fade opacity 1 → 0, and the `transitionend` event triggers `done()` to finalize the unmount. No `setTimeout`, no manual cleanup.
170+
- **Built on the Presence primitive** — the previous layer's mount lifecycle (mounted → leaving → unmounted) is managed by v0's `Presence` composable. CSS targets `data-state='leaving'` on the previous layer during its exit, and `transitionend` on the previous element calls `done()` to finalize the unmount. No `setTimeout`, no manual cleanup.
171171
- **Class routing**`class` goes on the wrapper `<div>` (layout, border-radius). `img-class` applies to both inner `<img>` elements (object-fit, sizing). `current-class` and `previous-class` target the individual layers — this is where transition/opacity rules live. The component ships no opinionated styling: you write the fade behavior against `data-state`, `data-has-previous` (on current while a swap is in flight), and Presence's `data-[state=leaving]` (on previous during exit).
172+
- **`data-has-previous` pin is required** — during the hold window the current layer is still `data-state='loading'`; once the load fires, both layers transition simultaneously (new fades 0→1, previous fades 1→0). Without `data-[has-previous]:opacity-100` on `current-class` to pin the current layer opaque through the swap, the two simultaneous fades show background bleed-through (a dim flash) in the middle of the crossfade. Treat it as a required rule, not informational state.
173+
- **`previous-class` must include at least one CSS transition**`done()` is driven by `transitionend` on the previous element. If `previous-class` has no CSS transition at all, the event never fires and the previous layer leaks in the DOM. Opacity is the usual choice; transform, filter, or any other transitioned property works equivalently.
172174
- **Error state** — if the new image errors, the old one stays visible underneath and `Image.Fallback` overlays as usual. Call `retry()` from the Root slot props or the Fallback slot props to re-attempt the same source.
173175

174176
Not a replacement for `Image.Img` in every case — if you don't need cross-src transitions (single content image, hero banner, avatars), stick with `Image.Img` for the simpler DOM.

packages/0/src/components/Image/ImageSwap.vue

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,10 @@
132132
// Only capture the previous source when no transition is in flight —
133133
// navigating through several sources before any have loaded should
134134
// keep the original previous visible rather than flashing through
135-
// each intermediate URL.
135+
// each intermediate URL. Also skips when newSrc is falsy: clearing
136+
// src to undefined/empty and restoring it degrades to a hard swap,
137+
// which is the correct trade-off since a cleared src means the
138+
// consumer has intentionally unmounted the logical image.
136139
if (oldSrc && newSrc && newSrc !== oldSrc && !showPrevious.value) {
137140
previousSrc.value = oldSrc
138141
showPrevious.value = true
@@ -169,8 +172,11 @@
169172
emit('error', e)
170173
}
171174
172-
function onPresenceLeave (e: Event, done: () => void) {
173-
if ((e as TransitionEvent).propertyName === 'opacity') done()
175+
function onPresenceLeave (_e: Event, done: () => void) {
176+
// Accept any transitionend, not just opacity — consumers are free to
177+
// build exits from transform, filter, etc. Requires that previous-class
178+
// include at least one CSS transition so the event fires at all.
179+
done()
174180
}
175181
176182
const slotProps = toRef((): ImageSwapSlotProps => ({

0 commit comments

Comments
 (0)