react-native-fast-image 10.1.1 → 10.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +107 -14
  2. package/android/src/main/java/com/fastimage/FastImageBlur.java +3 -2
  3. package/android/src/main/java/com/fastimage/FastImageThumbHash.java +246 -0
  4. package/android/src/main/java/com/fastimage/FastImageViewConverter.java +1 -1
  5. package/android/src/main/java/com/fastimage/FastImageViewManager.java +20 -5
  6. package/android/src/main/java/com/fastimage/FastImageViewWithUrl.java +125 -48
  7. package/dist/index.cjs.js +47 -9
  8. package/dist/index.cjs.js.flow +11 -0
  9. package/dist/index.cjs.web.js +67 -14
  10. package/dist/index.cjs.web.js.flow +11 -0
  11. package/dist/index.d.ts +77 -13
  12. package/dist/index.js +48 -10
  13. package/dist/index.js.flow +11 -0
  14. package/dist/index.web.js +68 -15
  15. package/dist/index.web.js.flow +11 -0
  16. package/dist/placeholders.d.ts +4 -0
  17. package/dist/specs/FastImageViewNativeComponent.d.ts +2 -1
  18. package/dist/thumbhash.cjs.js +241 -0
  19. package/dist/thumbhash.d.ts +12 -0
  20. package/dist/thumbhash.js +240 -0
  21. package/ios/FastImage/FFFBoxBlur.h +7 -0
  22. package/ios/FastImage/FFFBoxBlur.m +29 -0
  23. package/ios/FastImage/FFFThumbHash.h +24 -0
  24. package/ios/FastImage/FFFThumbHash.m +269 -0
  25. package/ios/FastImage/FFFastImageView.h +7 -1
  26. package/ios/FastImage/FFFastImageView.m +93 -46
  27. package/ios/FastImage/FFFastImageViewComponentView.mm +11 -4
  28. package/package.json +4 -2
  29. package/src/index.js.flow +11 -0
  30. package/src/index.tsx +166 -31
  31. package/src/index.web.tsx +166 -33
  32. package/src/placeholders.ts +45 -0
  33. package/src/specs/FastImageViewNativeComponent.ts +8 -2
  34. package/src/thumbhash.ts +363 -0
  35. package/thumbhash/package.json +8 -0
package/src/index.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import React, { forwardRef, memo } from 'react'
1
+ import React, { forwardRef, memo, useMemo } from 'react'
2
2
  import {
3
3
  ColorValue,
4
4
  View,
@@ -16,6 +16,7 @@ import { fromStyle, resolveObjectFit } from './objectFit'
16
16
  import { objectPositionOrCentered, type ObjectPosition } from './objectPosition'
17
17
  import FastImageView from './specs/FastImageViewNativeComponent'
18
18
  import NativeFastImageModule from './specs/NativeFastImageModule'
19
+ import { dataUri, hasSourcePlaceholder, isImageName } from './placeholders'
19
20
  import { warnIfChildren, warnOnce } from './warnings'
20
21
 
21
22
  /** How the image fits the view: see [`objectFit`](#objectfit). */
@@ -53,6 +54,8 @@ export type Source = {
53
54
  * - `'normal'`: the default.
54
55
  * - `'high'`: e.g. the image the screen is about.
55
56
  *
57
+ * On the web, it's the image's `fetchpriority`.
58
+ *
56
59
  * @default 'normal'
57
60
  */
58
61
  priority?: Priority
@@ -129,6 +132,32 @@ export type Source = {
129
132
  * @default true
130
133
  */
131
134
  diskCache?: boolean
135
+ /**
136
+ * A [ThumbHash](https://evanw.github.io/thumbhash/) of the image, shown
137
+ * blurred while the image loads, and if it fails to load. It's a short
138
+ * string you make when the image is uploaded (e.g. with the `thumbhash`
139
+ * package) and send with the image's url.
140
+ *
141
+ * On the web, it needs `import 'react-native-fast-image/thumbhash'` (see
142
+ * [ThumbHashes on the web](#thumbhashes-on-the-web)).
143
+ *
144
+ * @example
145
+ * ```jsx
146
+ * <FastImage source={{ uri: photo.url, thumbhash: photo.thumbhash }} />
147
+ * ```
148
+ */
149
+ thumbhash?: string
150
+ /**
151
+ * A tiny version of the image (e.g. 8 to 20 px) as a data uri, shown
152
+ * blurred while the image loads, and if it fails to load. On the web it
153
+ * shows without any JavaScript, e.g. in a server-rendered page.
154
+ *
155
+ * @example
156
+ * ```jsx
157
+ * <FastImage source={{ uri: photo.url, blurDataUri: photo.lqip }} />
158
+ * ```
159
+ */
160
+ blurDataUri?: string
132
161
  /**
133
162
  * With several sources (`source` as an array), the image's width at this
134
163
  * uri, in pixels (times `scale`, if given).
@@ -143,6 +172,9 @@ export type Source = {
143
172
  scale?: number
144
173
  }
145
174
 
175
+ /** A `thumbhash` or `blurDataUri` as `placeholder`. */
176
+ export type SourcePlaceholder = Pick<Source, 'thumbhash' | 'blurDataUri'>
177
+
146
178
  /** How the image fades in: see [`transition`](#transition). */
147
179
  export type Transition = {
148
180
  /**
@@ -154,7 +186,7 @@ export type Transition = {
154
186
  /**
155
187
  * Also fades between images: a new `source` replacing an image that's
156
188
  * showing cross-dissolves from it. Otherwise only an image that appears
157
- * over nothing (or over `defaultSource`) fades in, and a new source
189
+ * over nothing (or over a placeholder) fades in, and a new source
158
190
  * replaces the image showing at once, once it has loaded.
159
191
  * @default false
160
192
  */
@@ -323,14 +355,29 @@ export interface FastImageProps extends AccessibilityProps, ViewProps {
323
355
  */
324
356
  source?: Source | Source[] | ImageRequireSource
325
357
  /**
326
- * An asset loaded with `require(...)`, shown while the first image loads,
327
- * and if an image fails to load. When `source` changes, the previous image
328
- * shows while the new one loads instead (see `source` and
329
- * `recyclingKey`).
358
+ * A placeholder shown while the image loads, and if it fails to load:
359
+ *
360
+ * - An image loaded with `require()`: `placeholder={require('./gray.png')}`.
361
+ * - A data uri: `placeholder="data:image/png;base64,…"`.
362
+ * - The name of an image bundled with the app (an iOS asset catalog image
363
+ * or an Android drawable): `placeholder="gray"`.
364
+ * - A [ThumbHash](#sourcethumbhash), shown blurred:
365
+ * `placeholder={{ thumbhash }}`.
366
+ * - A tiny image as a data uri, shown blurred:
367
+ * `placeholder={{ blurDataUri }}`.
368
+ *
369
+ * A source's own `thumbhash` or `blurDataUri` is shown instead, if it has
370
+ * one.
330
371
  *
331
- * On Android, `defaultSource` doesn't show in debug builds: there the dev
332
- * server serves `require()`d images, and `defaultSource` is only loaded
333
- * from the app's resources.
372
+ * Notes:
373
+ *
374
+ * - A url isn't loaded: it's ignored, with a warning in development.
375
+ * - On Android, a `require()`d image doesn't show in debug builds, where
376
+ * the dev server serves it. Release builds show it.
377
+ */
378
+ placeholder?: ImageRequireSource | string | SourcePlaceholder
379
+ /**
380
+ * @deprecated Use `placeholder`, which does the same.
334
381
  */
335
382
  defaultSource?: ImageRequireSource
336
383
  /**
@@ -373,8 +420,8 @@ export interface FastImageProps extends AccessibilityProps, ViewProps {
373
420
  * `{ right: 10, bottom: '20%' }`. A missing axis is centered.
374
421
  *
375
422
  * Points are dp on Android. An invalid value is ignored (the image is
376
- * centered), with a warning in development. `defaultSource` is placed
377
- * the same way.
423
+ * centered), with a warning in development. Placeholders are placed the
424
+ * same way.
378
425
  *
379
426
  * @default '50% 50%'
380
427
  * @example
@@ -387,7 +434,7 @@ export interface FastImageProps extends AccessibilityProps, ViewProps {
387
434
  * For views that get reused for other content, such as rows in FlashList
388
435
  * or recyclerlistview. Set it to something that identifies the content,
389
436
  * e.g. the item's id. When it changes, the image is cleared right away (to
390
- * `defaultSource`, or blank) instead of staying until the new one has
437
+ * `placeholder`, or blank) instead of staying until the new one has
391
438
  * loaded. Unlike changing `key`, this keeps the view, which is what list
392
439
  * recycling is for.
393
440
  *
@@ -429,6 +476,25 @@ export interface FastImageProps extends AccessibilityProps, ViewProps {
429
476
  * @default 'auto'
430
477
  */
431
478
  imageRendering?: 'auto' | 'smooth' | 'pixelated'
479
+ /**
480
+ * Whether the image loads as it nears the screen (`'lazy'`) or at once
481
+ * (`'eager'`).
482
+ *
483
+ * @default 'lazy'
484
+ * @platform web
485
+ */
486
+ loading?: 'lazy' | 'eager'
487
+ /**
488
+ * With several sources, the width the image is shown at, as the image's
489
+ * [`sizes`](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/img#sizes),
490
+ * so the browser loads the size that fits. The default is the width the
491
+ * image is laid out at, which needs `loading="lazy"` (otherwise, or in
492
+ * browsers without it, the window's width).
493
+ *
494
+ * @default 'auto, 100vw'
495
+ * @platform web
496
+ */
497
+ sizes?: string
432
498
  /**
433
499
  * Pauses an animated image (GIF, animated WebP, APNG, or animated AVIF) on
434
500
  * the frame it's showing; `false` plays it again from there
@@ -499,7 +565,7 @@ export interface FastImageProps extends AccessibilityProps, ViewProps {
499
565
  * blurring a photo behind a sheet). Each change blurs the image again on
500
566
  * the CPU, so don't animate it.
501
567
  *
502
- * - Only the loaded image is blurred, not `defaultSource`.
568
+ * - Only the loaded image is blurred, not a placeholder.
503
569
  * - An animated image (GIF, animated WebP, APNG or AVIF) shows its first frame,
504
570
  * blurred, and doesn't animate.
505
571
  * - With `tintColor`, the blurred image is tinted.
@@ -588,7 +654,7 @@ export interface FastImageProps extends AccessibilityProps, ViewProps {
588
654
 
589
655
  /**
590
656
  * If supplied, changes the color of all the non-transparent pixels to the
591
- * given color.
657
+ * given color, including the placeholder.
592
658
  */
593
659
  tintColor?: ColorValue
594
660
 
@@ -601,13 +667,46 @@ export interface FastImageProps extends AccessibilityProps, ViewProps {
601
667
  children?: "FastImage doesn't take children: use FastImageBackground for content over an image"
602
668
  }
603
669
 
604
- const resolveDefaultSource = (
605
- defaultSource?: ImageRequireSource,
670
+ // A source without its thumbhash and blurDataUri.
671
+ function withoutPlaceholders<T>(source: T): T {
672
+ if (!hasSourcePlaceholder(source)) return source
673
+ const {
674
+ thumbhash: _thumbhash,
675
+ blurDataUri: _blurDataUri,
676
+ ...rest
677
+ } = source as Source
678
+ return rest as T
679
+ }
680
+
681
+ // What the native view shows while a source loads: its ThumbHash (decoded
682
+ // natively), or its blurDataUri, or '' for neither.
683
+ function sourcePlaceholder(source: unknown): string {
684
+ if (!source || typeof source !== 'object') return ''
685
+ const { thumbhash, blurDataUri } = source as Source
686
+ return thumbhash || dataUri(blurDataUri) || ''
687
+ }
688
+
689
+ const resolvePlaceholder = (
690
+ placeholder?: ImageRequireSource | string,
606
691
  ): string | object | null => {
607
- if (!defaultSource) {
692
+ if (typeof placeholder === 'string') {
693
+ // An image bundled with the app, by name, as React Native's Image
694
+ // takes it ({ uri: name }).
695
+ if (isImageName(placeholder)) {
696
+ return Platform.OS === 'android'
697
+ ? placeholder
698
+ : { uri: placeholder }
699
+ }
700
+ const uri = dataUri(placeholder)
701
+ if (!uri) return null
702
+ // Android decodes it; iOS takes it as an image source, as it does a
703
+ // require()d image's.
704
+ return Platform.OS === 'android' ? uri : { uri }
705
+ }
706
+ if (!placeholder) {
608
707
  return null
609
708
  }
610
- const resolved = Image.resolveAssetSource(defaultSource)
709
+ const resolved = Image.resolveAssetSource(placeholder)
611
710
  if (!resolved) {
612
711
  return null
613
712
  }
@@ -719,6 +818,7 @@ declare const __DEV__: boolean
719
818
 
720
819
  function FastImageBase({
721
820
  source,
821
+ placeholder,
722
822
  defaultSource,
723
823
  tintColor,
724
824
  onLoadStart,
@@ -732,6 +832,9 @@ function FastImageBase({
732
832
  objectPosition,
733
833
  loop,
734
834
  transition,
835
+ // Web only.
836
+ loading: _loading,
837
+ sizes: _sizes,
735
838
  forwardedRef,
736
839
  ...viewProps
737
840
  }: FastImageProps & { forwardedRef: React.Ref<any> }) {
@@ -765,18 +868,49 @@ function FastImageBase({
765
868
  const fit = resolveObjectFit(objectFit, style)
766
869
  const position = objectPositionOrCentered(objectPosition)
767
870
 
768
- // Several sources are picked from natively, for the view's size; one in
769
- // an array is a plain source.
770
- const sources = Array.isArray(source)
771
- ? source.length === 1
772
- ? undefined
773
- : source
774
- : undefined
775
- const single = Array.isArray(source) ? source[0] : source
776
- const resolvedSource = sources
777
- ? undefined
778
- : Image.resolveAssetSource(single as any)
779
- const resolvedDefaultSource = resolveDefaultSource(defaultSource)
871
+ // Memoized on source, so an unchanged source reaches the native view as
872
+ // the same objects, and React's prop diff stops at them.
873
+ // `placeholder` as a thumbhash or blurDataUri, for the sources without
874
+ // their own: a string, so an object made on every render doesn't make
875
+ // new sourcePlaceholders.
876
+ const shared = hasSourcePlaceholder(placeholder)
877
+ ? sourcePlaceholder(placeholder)
878
+ : ''
879
+ const { resolvedSource, sources, sourcePlaceholders } = useMemo(() => {
880
+ // Several sources are picked from natively, for the view's size; one
881
+ // in an array is a plain source.
882
+ const sources = Array.isArray(source)
883
+ ? source.length === 1
884
+ ? undefined
885
+ : source
886
+ : undefined
887
+ const single = Array.isArray(source) ? source[0] : source
888
+ // Sources' thumbhashes and blurDataUris go to the native view apart
889
+ // from the sources, so a new one for the same image doesn't load it
890
+ // again, by the source's index ('' for none). A source's own wins
891
+ // over `placeholder`'s.
892
+ const placeholders = (sources ?? [single]).map(
893
+ (entry) => sourcePlaceholder(entry) || shared,
894
+ )
895
+ return {
896
+ resolvedSource: sources
897
+ ? undefined
898
+ : Image.resolveAssetSource(withoutPlaceholders(single) as any),
899
+ sources: sources?.some(hasSourcePlaceholder)
900
+ ? sources.map(withoutPlaceholders)
901
+ : sources,
902
+ sourcePlaceholders: placeholders.some(Boolean)
903
+ ? placeholders
904
+ : undefined,
905
+ }
906
+ }, [source, shared])
907
+ // A thumbhash or blurDataUri in `placeholder` goes in sourcePlaceholders
908
+ // instead: it isn't an image to show as it is, but defaultSource still is.
909
+ const resolvedPlaceholder = resolvePlaceholder(
910
+ hasSourcePlaceholder(placeholder)
911
+ ? defaultSource
912
+ : (placeholder ?? defaultSource),
913
+ )
780
914
 
781
915
  return (
782
916
  <FastImageView
@@ -794,7 +928,8 @@ function FastImageBase({
794
928
  )}
795
929
  source={resolvedSource}
796
930
  sources={sources}
797
- defaultSource={resolvedDefaultSource}
931
+ sourcePlaceholders={sourcePlaceholders}
932
+ placeholder={resolvedPlaceholder}
798
933
  onFastImageLoadStart={onLoadStart}
799
934
  onFastImageProgress={onProgress}
800
935
  // The native views only send the events with a handler (and only
package/src/index.web.tsx CHANGED
@@ -5,20 +5,23 @@
5
5
  //
6
6
  // The View (a div) has FastImage's style, ref and View props. The <img> fills
7
7
  // it inside its borders, with object-fit for objectFit (see Picture).
8
- // defaultSource is a second <img> under it until the image has loaded (the
8
+ // The placeholder is a second <img> under it until the image has loaded (the
9
9
  // browser draws the image over it as it arrives), and tintColor is an SVG
10
10
  // filter next to them.
11
11
  //
12
- // Works: source (uri, require(), or several sizes: see sizedSources),
13
- // defaultSource, objectFit, objectPosition, tintColor, blurRadius, style,
14
- // onLoadStart, onLoad, onError, onLoadEnd, and View props (testID,
15
- // accessibility, onLayout, pointerEvents). The native-only props (cache,
16
- // priority, headers, transition, downsample, loop, paused, imageRendering,
17
- // recyclingKey) and onProgress are ignored.
12
+ // Works: source (uri, require(), or several sizes: see sizedSources, and
13
+ // thumbhash and blurDataUri), placeholder (and defaultSource), objectFit,
14
+ // objectPosition, tintColor, blurRadius, style, source.priority (as
15
+ // fetchpriority), the web-only loading and sizes, onLoadStart, onLoad,
16
+ // onError, onLoadEnd, and View props (testID, accessibility, onLayout,
17
+ // pointerEvents). The native-only props (cache, headers, transition,
18
+ // downsample, loop, paused, imageRendering, recyclingKey) and onProgress
19
+ // are ignored.
18
20
  import React, {
19
21
  forwardRef,
20
22
  memo,
21
23
  useEffect,
24
+ useId,
22
25
  useLayoutEffect,
23
26
  useRef,
24
27
  useState,
@@ -29,7 +32,8 @@ import { getAssetByID } from 'react-native-web/dist/modules/AssetRegistry'
29
32
  import { cacheControl, priority, resizeMode } from './constants'
30
33
  import { resolveObjectFit } from './objectFit'
31
34
  import { cssPosition, objectPositionOrCentered } from './objectPosition'
32
- import { warnIfChildren } from './warnings'
35
+ import { dataUri, hasSourcePlaceholder } from './placeholders'
36
+ import { warnIfChildren, warnOnce } from './warnings'
33
37
  import type {
34
38
  CachePathResult,
35
39
  CacheState,
@@ -38,7 +42,9 @@ import type {
38
42
  FastImageStaticProperties,
39
43
  ObjectFit,
40
44
  PreloadResult,
45
+ Priority,
41
46
  Source,
47
+ SourcePlaceholder,
42
48
  } from './index'
43
49
 
44
50
  export type * from './index'
@@ -49,15 +55,6 @@ const notSupported: CachePathResult = {
49
55
  error: 'Not supported on the web',
50
56
  }
51
57
 
52
- // An id that's the same on the server and in the browser: React's useId,
53
- // from React 18 (this package's React types are older), without the
54
- // characters an SVG id can't have (:r0: in React 18, «r0» in React 19). On
55
- // React 17, a counter's (which can differ from the server's).
56
- let ids = 0
57
- const useCounterId = () => useState(() => `${++ids}`)[0]
58
- const useSvgId: () => string =
59
- (React as unknown as { useId?: () => string }).useId ?? useCounterId
60
-
61
58
  // A layout effect in the browser (it runs before the image can show), and an
62
59
  // effect when rendering on a server, where layout effects warn.
63
60
  const useClientLayoutEffect =
@@ -71,6 +68,7 @@ declare const __DEV__: boolean | undefined
71
68
 
72
69
  function FastImageBase({
73
70
  source,
71
+ placeholder,
74
72
  defaultSource,
75
73
  tintColor,
76
74
  blurRadius,
@@ -82,6 +80,8 @@ function FastImageBase({
82
80
  children,
83
81
  objectFit,
84
82
  objectPosition,
83
+ loading,
84
+ sizes,
85
85
  forwardedRef,
86
86
  // On the wrapper, the view the layout and touches are for here (on
87
87
  // native, FastImage's only view).
@@ -121,13 +121,29 @@ function FastImageBase({
121
121
  // one doesn't stay while it loads; with several sizes, the <img> stays,
122
122
  // and the browser shows the previous image until the new one has loaded.
123
123
  const key = srcSet ?? src
124
- const defaultUri = resolveUri(defaultSource)
124
+ const priority = sourcePriority(source)
125
+ // The source's blurDataUri, or its ThumbHash made into one (with
126
+ // `react-native-fast-image/thumbhash`), shows blurred (see PreviewBlur)
127
+ // in place of `placeholder` (or `placeholder`'s, for a source without its
128
+ // own). With several sizes the browser picks one itself (from the srcset,
129
+ // as it loads), so they only show if all the sizes have the same one.
130
+ const preview = hasSourcePlaceholder(placeholder) ? placeholder : undefined
131
+ const blurUri =
132
+ resolveUri(dataUri(shared(source, 'blurDataUri', preview))) ??
133
+ thumbhashUri(shared(source, 'thumbhash', preview))
134
+ // A thumbhash or blurDataUri in `placeholder` isn't an image to show as it
135
+ // is: defaultSource still is.
136
+ const defaultUri =
137
+ blurUri ??
138
+ resolveUri(preview ? defaultSource : (placeholder ?? defaultSource))
125
139
  // Whether the image for `key` loaded, or failed.
126
140
  const [result, setResult] = useState<{ key: string; ok: boolean }>()
127
141
  const ok = result && result.key === key ? result.ok : undefined
128
142
  // The same on the server and in the browser. React's ids have characters
129
143
  // an SVG id can't (:r0: in React 18, «r0» in React 19).
130
- const tintId = `fast-image-tint-${useSvgId().replace(/[^\w-]/g, '')}`
144
+ const svgId = useId().replace(/[^\w-]/g, '')
145
+ const tintId = `fast-image-tint-${svgId}`
146
+ const blurId = `fast-image-blur-${svgId}`
131
147
  // objectFit (the prop, then style's).
132
148
  const fit = resolveObjectFit(objectFit, style)
133
149
  const position = cssPosition(objectPositionOrCentered(objectPosition))
@@ -138,7 +154,7 @@ function FastImageBase({
138
154
  ]
139
155
  .filter(Boolean)
140
156
  .join(' ') || undefined
141
- // defaultSource shows until the image has loaded, and if it fails.
157
+ // The placeholder shows until the image has loaded, and if it fails.
142
158
  const showDefault = !!defaultUri && ok !== true
143
159
 
144
160
  useClientLayoutEffect(() => {
@@ -160,9 +176,31 @@ function FastImageBase({
160
176
  <Picture
161
177
  key={`default ${defaultUri}`}
162
178
  src={defaultUri}
163
- fit={fit}
179
+ // Loaded when the image is (a data uri needs no request).
180
+ loading={loading}
181
+ priority={priority}
182
+ // A source's placeholder stands for an image whose size
183
+ // isn't known yet: as contain for scale-down and none,
184
+ // which show an image at its own size.
185
+ fit={
186
+ blurUri && (fit === 'scale-down' || fit === 'none')
187
+ ? 'contain'
188
+ : fit
189
+ }
164
190
  position={position}
165
- filter={filter}
191
+ // A source's preview gets its own blur and the tint, not
192
+ // blurRadius (it's blurred already), as on iOS and
193
+ // Android.
194
+ filter={
195
+ blurUri
196
+ ? [
197
+ `url(#${blurId})`,
198
+ tintColor ? `url(#${tintId})` : '',
199
+ ]
200
+ .filter(Boolean)
201
+ .join(' ')
202
+ : filter
203
+ }
166
204
  // The image has the label, if there is one.
167
205
  alt={key ? '' : (label ?? '')}
168
206
  />
@@ -172,11 +210,13 @@ function FastImageBase({
172
210
  key={several ? 'sizes' : key}
173
211
  src={src}
174
212
  srcSet={srcSet}
175
- lazy={!!several}
213
+ sizes={sizes}
214
+ loading={loading}
215
+ priority={priority}
176
216
  fit={fit}
177
217
  position={position}
178
218
  filter={filter}
179
- // Shown as it loads, over defaultSource, and not if it
219
+ // Shown as it loads, over the placeholder, and not if it
180
220
  // failed (no broken image icon or alt text).
181
221
  hidden={ok === false}
182
222
  alt={label ?? ''}
@@ -195,11 +235,66 @@ function FastImageBase({
195
235
  />
196
236
  ) : null}
197
237
  {tintColor ? <TintFilter id={tintId} color={tintColor} /> : null}
238
+ {blurUri && showDefault ? <PreviewBlur id={blurId} /> : null}
198
239
  </View>
199
240
  )
200
241
  }
201
242
 
202
- // A source's (or defaultSource's) uri. Expo's web builds make a require()d
243
+ // A ThumbHash as a blurDataUri, with `react-native-fast-image/thumbhash`
244
+ // imported (see thumbhash.ts). Without it, ignored, with a warning in
245
+ // development: FastImage doesn't bundle the decoder on the web.
246
+ function thumbhashUri(hash: string | undefined): string | undefined {
247
+ if (!hash) return undefined
248
+ const decode = (globalThis as Record<symbol, unknown>)[
249
+ Symbol.for('react-native-fast-image/thumbhash')
250
+ ] as ((hash: string) => string | undefined) | undefined
251
+ if (decode) return decode(hash)
252
+ if (typeof __DEV__ !== 'undefined' && __DEV__) {
253
+ warnOnce(
254
+ 'thumbhash-web',
255
+ "react-native-fast-image: on the web, a `thumbhash` shows only with `import 'react-native-fast-image/thumbhash'` (it decodes them). Or make it a `blurDataUri` before it reaches the browser (e.g. on your server) with that module's thumbHashToBlurDataUri, which needs no JavaScript in the browser.",
256
+ 'warn',
257
+ )
258
+ }
259
+ return undefined
260
+ }
261
+
262
+ // A source's priority: one source's, or the highest of several sizes'.
263
+ function sourcePriority(
264
+ source: FastImageProps['source'],
265
+ ): Priority | undefined {
266
+ const values = (Array.isArray(source) ? source : [source]).map((entry) =>
267
+ entry && typeof entry === 'object'
268
+ ? (entry as Source).priority
269
+ : undefined,
270
+ )
271
+ return (['high', 'normal', 'low'] as const).find((priority) =>
272
+ values.includes(priority),
273
+ )
274
+ }
275
+
276
+ // The thumbhash or blurDataUri a source shows: its own, or `placeholder`'s
277
+ // (preview) if it has neither. With several sources, the one they all show.
278
+ function shared(
279
+ source: FastImageProps['source'],
280
+ key: 'thumbhash' | 'blurDataUri',
281
+ preview: SourcePlaceholder | undefined,
282
+ ): string | undefined {
283
+ const values = (Array.isArray(source) ? source : [source]).map((entry) => {
284
+ const own =
285
+ entry && typeof entry === 'object' ? (entry as Source) : undefined
286
+ // A blurDataUri that isn't a data uri is ignored (with a warning), as
287
+ // on iOS and Android.
288
+ return (own?.thumbhash || dataUri(own?.blurDataUri) ? own : preview)?.[
289
+ key
290
+ ]
291
+ })
292
+ return values.every((value) => value && value === values[0])
293
+ ? values[0]
294
+ : undefined
295
+ }
296
+
297
+ // A source's (or a placeholder's) uri. Expo's web builds make a require()d
203
298
  // image a { uri, width, height } object (or a string). Other setups can make
204
299
  // it a number in react-native-web's asset registry, resolved as its Image
205
300
  // does: the file for the scale closest to the screen's.
@@ -242,9 +337,9 @@ type PackagerAsset = {
242
337
  // Several sizes of an image, as a srcset (each size's width, times its
243
338
  // scale), from which the browser loads the one for the image's width in
244
339
  // device pixels, usually the smallest at least as wide, as expo-image does.
245
- // sizes="auto" has the browser use the width the image is laid out at; it
246
- // only applies to lazy images, and browsers without it use the next value,
247
- // 100vw (the viewport's width). src is the largest, for browsers without
340
+ // sizes="auto" (the default sizes) has the browser use the width the image
341
+ // is laid out at; it only applies to lazy images, and otherwise, or in
342
+ // browsers without it, the next value, 100vw (the viewport's width). src is the largest, for browsers without
248
343
  // srcset.
249
344
  function sizedSources(sources: Source[]) {
250
345
  const widths = sources
@@ -316,7 +411,9 @@ const fill = {
316
411
  function Picture({
317
412
  src,
318
413
  srcSet,
319
- lazy,
414
+ sizes,
415
+ loading,
416
+ priority,
320
417
  fit,
321
418
  position,
322
419
  filter,
@@ -327,8 +424,11 @@ function Picture({
327
424
  }: {
328
425
  src: string
329
426
  srcSet?: string
330
- // Several sizes load lazily, for sizes="auto" (see sizedSources).
331
- lazy?: boolean
427
+ // The props (see sizedSources for sizes).
428
+ sizes?: string
429
+ loading?: 'lazy' | 'eager'
430
+ // source.priority, as the <img>'s fetchpriority.
431
+ priority?: Priority
332
432
  fit: ObjectFit
333
433
  // objectPosition, as CSS.
334
434
  position: string
@@ -399,8 +499,12 @@ function Picture({
399
499
  ref={image}
400
500
  src={src}
401
501
  srcSet={srcSet}
402
- sizes={srcSet ? 'auto, 100vw' : undefined}
403
- loading={lazy ? 'lazy' : undefined}
502
+ sizes={srcSet ? (sizes ?? 'auto, 100vw') : undefined}
503
+ loading={loading === 'eager' ? undefined : 'lazy'}
504
+ // For 'high' and 'low' (the browser's own for 'normal').
505
+ fetchPriority={
506
+ priority === 'high' || priority === 'low' ? priority : undefined
507
+ }
404
508
  alt={alt}
405
509
  draggable={false}
406
510
  style={{
@@ -439,6 +543,35 @@ function TintFilter({ id, color }: { id: string; color: unknown }) {
439
543
  )
440
544
  }
441
545
 
546
+ // A source's blurDataUri, a few pixels, blurred as it's scaled up: by about
547
+ // half a pixel of an 8 px image, in units of its size, so the same filter
548
+ // works for any view, also rendered on a server (no layout needed). The blur
549
+ // fades the edges, which the alpha table restores (transparent areas stay
550
+ // transparent): Chrome ignores edgeMode, and forcing alpha to 1 would fill
551
+ // a transparent image's background.
552
+ function PreviewBlur({ id }: { id: string }) {
553
+ return (
554
+ <svg style={tintStyle}>
555
+ <defs>
556
+ <filter
557
+ id={id}
558
+ x="0"
559
+ y="0"
560
+ width="1"
561
+ height="1"
562
+ primitiveUnits="objectBoundingBox"
563
+ colorInterpolationFilters="sRGB"
564
+ >
565
+ <feGaussianBlur stdDeviation="0.06" />
566
+ <feComponentTransfer>
567
+ <feFuncA type="table" tableValues="0 1 1 1 1" />
568
+ </feComponentTransfer>
569
+ </filter>
570
+ </defs>
571
+ </svg>
572
+ )
573
+ }
574
+
442
575
  const tintStyle = {
443
576
  position: 'absolute',
444
577
  width: 0,