The CWA is in heavy development
The CWA is still in alpha and not ready for production - some code and implementations are likely to change. If you would like to try out the CWA, please enjoy what we have provided and feel free to provide feedback, or get involved on GitHub.
DraftComponent

useCwaFileField

Resolve a single uploadable file field on a resource — content URL, display media, load state, and Imagine filter variants — named at the call site.

useCwaFileField resolves one uploadable file field on a component backed by a PHP entity with #[Silverback\Uploadable]. It returns the display media, its content URL, and load state for that field. Because you name the field at the call site, one component can wire several file fields without them colliding.

It's the standalone form of the withFile() plugin — the same resolver under the hood, called directly instead of through useCwaComponent's plugin array.

Renamed from useCwaImageResource (removed). The file APIs handle any uploadable file, not just images — imagineFilterName is an image-specific convenience.
import { useTemplateRef } from 'vue'
import { useCwaFileField } from '#imports'
import type { IriProp } from '#cwa/composables/cwa-resource'

const props = defineProps<IriProp>()

const imageRef = useTemplateRef<unknown>('file')
const { contentUrl, displayMedia, handleLoad, loaded } = useCwaFileField(props, {
  imagineFilterName: 'thumbnail',
  imageRef
})

Signature

useCwaFileField(
  props: IriProp,                          // your component's props (with `iri`)
  fileOps?: {
    fileProp?: string                      // the PHP #[UploadableField] property (default: 'file')
    imagineFilterName?: string             // Imagine filter variant to surface as displayMedia
    imageRef?: Readonly<ShallowRef<unknown>>  // opt in to cached-image load detection
  }
)
  • Pass props directly — no need to toRef the iri; the composable resolves the resource itself.
  • fileProp — which uploadable field to read, i.e. which key of _metadata.mediaObjects to resolve. Defaults to 'file'.
  • imagineFilterName — the LiipImagineBundle filter to surface as displayMedia/contentUrl (e.g. 'thumbnail', 'hero'). Omit for the original uploaded file.
  • imageRef — a template ref pointing at the <img> that renders this file. Optional, and never registered for you. See below.

Why imageRef exists

An <img> served from cache can finish loading before Vue attaches the @load listener. When that happens @load never fires, loaded stays false forever, and any placeholder gated on it is stuck over a fully-loaded image.

imageRef exists solely to close that gap: on mount, the composable reads the element's own load state (complete / naturalHeight) and calls handleLoad() itself if the image is already there. That is its only job — contentUrl and displayMedia come from resource data and never touch it.

So the rule is simple:

  • Rendering an <img> and want the cached case handled? Pass imageRef.
  • Anything else — a PDF, a zip, any non-image field — omit it. Only an <img> can report its own load state, so @load is the sole source of truth. A ref that can't report it (a <div>, an unmatched ref name) is ignored at runtime.

Both a bare <img> and a component wrapping one (<NuxtImg>) work — a component ref resolves to the instance and is unwrapped to its root <img>.

Breaking change.imageRef used to be registered implicitly from fileProp via useTemplateRef(fileProp). It no longer is — if you relied on load detection, you must now pass the ref yourself, or loaded will never flip for a cached image and your placeholder will never fade.
// before — ref auto-registered from fileProp
const { loaded } = useCwaFileField(props, { imagineFilterName: 'thumbnail' })

// after — register it yourself
const imageRef = useTemplateRef<unknown>('file')
const { loaded } = useCwaFileField(props, { imagineFilterName: 'thumbnail', imageRef })
The implicit ref was removed because two calls in one component both defaulted to the key 'file' and collided — TypeError: Cannot redefine property: file, thrown only in production builds (dev merely warned). It also only ever worked if you happened to name your template ref exactly the fileProp, and did nothing at all otherwise.

fileProp no longer has any relationship to a template ref name — name the ref whatever you like. ref="file" is convention, not a requirement.

Return values

ReturnTypePurpose
contentUrlComputedRef<string | undefined>URL of the (optionally filtered) file
displayMediaComputedRef<MediaFile | undefined>The media object to display — has contentUrl, mimeType, width, height; undefined until available
handleLoad() => voidCall on the <img>'s @load event
loadedRef<boolean>true once handleLoad() fires

Example

<template>
  <div class="relative">
    <NuxtImg
      v-show="loaded"
      ref="file"
      :src="contentUrl"
      :width="displayMedia?.width"
      :height="displayMedia?.height"
      class="w-full object-cover"
      @load="handleLoad"
    />
    <div v-if="!loaded" class="skeleton h-48 w-full bg-gray-200 animate-pulse" />
  </div>
</template>

<script setup lang="ts">
import { useTemplateRef } from 'vue'
import { useCwaFileField } from '#imports'
import type { IriProp } from '#cwa/composables/cwa-resource'

const props = defineProps<IriProp>()

const imageRef = useTemplateRef<unknown>('file')
const { contentUrl, displayMedia, handleLoad, loaded } = useCwaFileField(props, {
  imagineFilterName: 'thumbnail',
  imageRef
})
</script>
Use v-show, not v-if, when gating the image on loaded.loaded is set by that element's own @load, so v-if="loaded" is a deadlock — the element is never created, so it never loads, so loaded never becomes true. v-show keeps it in the DOM so it can load and fade in.

Type the ref as unknown

Declare the ref as useTemplateRef<unknown>('file'). Without the annotation, useTemplateRef infers its type from the template — and if the template also reads this composable's own result, that inference is circular and vue-tsc fails:

TS7022: 'imageRef' implicitly has type 'any' because it does not have a type annotation
and is referenced directly or indirectly in its own initializer.

unknown is also honest: on a component like <NuxtImg> the ref's value is the component instance, not an HTMLImageElement.

Multiple file fields

Call it once per field, naming each with fileProp:

const hero  = useCwaFileField(props, { fileProp: 'heroImage' })
const thumb = useCwaFileField(props, { fileProp: 'thumbnail' })
// hero.contentUrl, thumb.contentUrl, …

Each call needs its own imageRef (pointing at its own template ref) if you want load detection for that field — they're independent.

If you're already calling useCwaComponent, prefer the plugin form — useCwaComponent(props, [withFile({ fileProp: 'heroImage' }), withFile({ fileProp: 'thumbnail' })]) exposes the same data under a files map.