react-native-fast-image 10.4.0 → 10.6.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.
package/src/index.tsx CHANGED
@@ -16,7 +16,12 @@ 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
+ import {
20
+ dataUri,
21
+ hasSourcePlaceholder,
22
+ isImageName,
23
+ placeholderSource,
24
+ } from './placeholders'
20
25
  import { warnIfChildren, warnOnce } from './warnings'
21
26
 
22
27
  /** How the image fits the view: see [`objectFit`](#objectfit). */
@@ -175,6 +180,12 @@ export type Source = {
175
180
  /** A `thumbhash` or `blurDataUri` as `placeholder`. */
176
181
  export type SourcePlaceholder = Pick<Source, 'thumbhash' | 'blurDataUri'>
177
182
 
183
+ /** A url as `placeholder`, loaded like a source (see `placeholder`). */
184
+ export type PlaceholderSource = Pick<
185
+ Source,
186
+ 'uri' | 'headers' | 'cacheKey' | 'cache'
187
+ >
188
+
178
189
  /** How the image fades in: see [`transition`](#transition). */
179
190
  export type Transition = {
180
191
  /**
@@ -351,9 +362,10 @@ export interface FastImageProps extends AccessibilityProps, ViewProps {
351
362
  * - `FastImage.preload`, `getCachePath` and `writeToCache` take one size:
352
363
  * pass the one a view will show. An array fails with
353
364
  * `{ ok: false, error }`.
365
+ * - A `require()`d image can be one of the sizes: its size is the file's.
354
366
  * - An array of one is the same as that source.
355
367
  */
356
- source?: Source | Source[] | ImageRequireSource
368
+ source?: Source | Array<Source | ImageRequireSource> | ImageRequireSource
357
369
  /**
358
370
  * A placeholder shown while the image loads, and if it fails to load:
359
371
  *
@@ -365,19 +377,30 @@ export interface FastImageProps extends AccessibilityProps, ViewProps {
365
377
  * `placeholder={{ thumbhash }}`.
366
378
  * - A tiny image as a data uri, shown blurred:
367
379
  * `placeholder={{ blurDataUri }}`.
380
+ * - A url, loaded and cached like a source:
381
+ * `placeholder="https://example.com/placeholder.png"`, or a source with
382
+ * `headers` or a `cacheKey`: `placeholder={{ uri, headers }}`.
383
+ * - A url only shown if it's already cached, e.g. a list's thumbnail while
384
+ * the full-size image loads: `placeholder={{ uri, cache: 'cacheOnly' }}`.
368
385
  *
369
386
  * A source's own `thumbhash` or `blurDataUri` is shown instead, if it has
370
- * one.
387
+ * one; a url placeholder shows over it once it has loaded.
371
388
  *
372
389
  * Notes:
373
390
  *
374
- * - A url isn't loaded: it's ignored, with a warning in development.
375
391
  * - On Android, a `require()`d image doesn't show in debug builds, where
376
392
  * the dev server serves it. Release builds show it.
393
+ * - On the web, a url loads like any image, also with `'cacheOnly'`: a
394
+ * browser can't look only in its cache.
377
395
  */
378
- placeholder?: ImageRequireSource | string | SourcePlaceholder
396
+ placeholder?:
397
+ | ImageRequireSource
398
+ | string
399
+ | SourcePlaceholder
400
+ | PlaceholderSource
379
401
  /**
380
- * @deprecated Use `placeholder`, which does the same.
402
+ * @deprecated Use `placeholder`, which does the same. With both,
403
+ * `placeholder` shows.
381
404
  */
382
405
  defaultSource?: ImageRequireSource
383
406
  /**
@@ -686,27 +709,9 @@ export interface FastImageProps extends AccessibilityProps, ViewProps {
686
709
  children?: "FastImage doesn't take children: use FastImageBackground for content over an image"
687
710
  }
688
711
 
689
- // A source without its thumbhash and blurDataUri.
690
- function withoutPlaceholders<T>(source: T): T {
691
- if (!hasSourcePlaceholder(source)) return source
692
- const {
693
- thumbhash: _thumbhash,
694
- blurDataUri: _blurDataUri,
695
- ...rest
696
- } = source as Source
697
- return rest as T
698
- }
699
-
700
- // What the native view shows while a source loads: its ThumbHash (decoded
701
- // natively), or its blurDataUri, or '' for neither.
702
- function sourcePlaceholder(source: unknown): string {
703
- if (!source || typeof source !== 'object') return ''
704
- const { thumbhash, blurDataUri } = source as Source
705
- return thumbhash || dataUri(blurDataUri) || ''
706
- }
707
-
708
712
  const resolvePlaceholder = (
709
- placeholder?: ImageRequireSource | string,
713
+ // A source object here has no url (e.g. { uri: name }, a bundled image).
714
+ placeholder?: ImageRequireSource | string | PlaceholderSource,
710
715
  ): string | object | null => {
711
716
  if (typeof placeholder === 'string') {
712
717
  // An image bundled with the app, by name, as React Native's Image
@@ -725,7 +730,9 @@ const resolvePlaceholder = (
725
730
  if (!placeholder) {
726
731
  return null
727
732
  }
728
- const resolved = Image.resolveAssetSource(placeholder)
733
+ const resolved = Image.resolveAssetSource(
734
+ placeholder as ImageRequireSource | { uri: string },
735
+ )
729
736
  if (!resolved) {
730
737
  return null
731
738
  }
@@ -887,49 +894,32 @@ function FastImageBase({
887
894
  const fit = resolveObjectFit(objectFit, style)
888
895
  const position = objectPositionOrCentered(objectPosition)
889
896
 
890
- // Memoized on source, so an unchanged source reaches the native view as
891
- // the same objects, and React's prop diff stops at them.
892
- // `placeholder` as a thumbhash or blurDataUri, for the sources without
893
- // their own: a string, so an object made on every render doesn't make
894
- // new sourcePlaceholders.
895
- const shared = hasSourcePlaceholder(placeholder)
896
- ? sourcePlaceholder(placeholder)
897
- : ''
898
- const { resolvedSource, sources, sourcePlaceholders } = useMemo(() => {
899
- // Several sources are picked from natively, for the view's size; one
900
- // in an array is a plain source.
901
- const sources = Array.isArray(source)
902
- ? source.length === 1
903
- ? undefined
904
- : source
905
- : undefined
906
- const single = Array.isArray(source) ? source[0] : source
907
- // Sources' thumbhashes and blurDataUris go to the native view apart
908
- // from the sources, so a new one for the same image doesn't load it
909
- // again, by the source's index ('' for none). A source's own wins
910
- // over `placeholder`'s.
911
- const placeholders = (sources ?? [single]).map(
912
- (entry) => sourcePlaceholder(entry) || shared,
913
- )
914
- return {
915
- resolvedSource: sources
916
- ? undefined
917
- : Image.resolveAssetSource(withoutPlaceholders(single) as any),
918
- sources: sources?.some(hasSourcePlaceholder)
919
- ? sources.map(withoutPlaceholders)
920
- : sources,
921
- sourcePlaceholders: placeholders.some(Boolean)
922
- ? placeholders
923
- : undefined,
897
+ // One source, or several in an array, picked from natively for the
898
+ // view's size (one in an array is a plain source), with their thumbhashes
899
+ // and blurDataUris: the native view doesn't load the image again when
900
+ // only those change. Memoized on source, so an unchanged source reaches
901
+ // the native view as the same object, and React's prop diff stops at it.
902
+ const resolvedSource = useMemo(() => {
903
+ // A blurDataUri that isn't a data uri is ignored, with a warning.
904
+ for (const entry of [source].flat()) {
905
+ dataUri((entry as Source | undefined)?.blurDataUri)
924
906
  }
925
- }, [source, shared])
926
- // A thumbhash or blurDataUri in `placeholder` goes in sourcePlaceholders
927
- // instead: it isn't an image to show as it is, but defaultSource still is.
928
- const resolvedPlaceholder = resolvePlaceholder(
929
- hasSourcePlaceholder(placeholder)
930
- ? defaultSource
931
- : (placeholder ?? defaultSource),
932
- )
907
+ const single =
908
+ Array.isArray(source) && source.length === 1 ? source[0] : source
909
+ return Array.isArray(single)
910
+ ? single.map((entry) => Image.resolveAssetSource(entry as any))
911
+ : Image.resolveAssetSource(single as any)
912
+ }, [source])
913
+ // `placeholder` as the native view takes it: a thumbhash or blurDataUri
914
+ // as it is (for the sources without their own), a url as a source loaded
915
+ // apart from the image, or an image shown at once. defaultSource only
916
+ // applies without one (an empty thumbhash or blurDataUri is none).
917
+ const resolvedPlaceholder = hasSourcePlaceholder(placeholder)
918
+ ? placeholder.thumbhash || dataUri(placeholder.blurDataUri)
919
+ ? placeholder
920
+ : resolvePlaceholder(defaultSource)
921
+ : (placeholderSource(placeholder) ??
922
+ resolvePlaceholder(placeholder ?? defaultSource))
933
923
 
934
924
  return (
935
925
  <FastImageView
@@ -946,8 +936,6 @@ function FastImageBase({
946
936
  defaultTransition ?? builtInTransition(),
947
937
  )}
948
938
  source={resolvedSource}
949
- sources={sources}
950
- sourcePlaceholders={sourcePlaceholders}
951
939
  placeholder={resolvedPlaceholder}
952
940
  onFastImageLoadStart={onLoadStart}
953
941
  onFastImageProgress={onProgress}
package/src/index.web.tsx CHANGED
@@ -32,7 +32,11 @@ import { getAssetByID } from 'react-native-web/dist/modules/AssetRegistry'
32
32
  import { cacheControl, priority, resizeMode } from './constants'
33
33
  import { resolveObjectFit } from './objectFit'
34
34
  import { cssPosition, objectPositionOrCentered } from './objectPosition'
35
- import { dataUri, hasSourcePlaceholder } from './placeholders'
35
+ import {
36
+ dataUri,
37
+ hasSourcePlaceholder,
38
+ placeholderSource,
39
+ } from './placeholders'
36
40
  import { warnIfChildren, warnOnce } from './warnings'
37
41
  import type {
38
42
  CachePathResult,
@@ -132,11 +136,24 @@ function FastImageBase({
132
136
  const blurUri =
133
137
  resolveUri(dataUri(shared(source, 'blurDataUri', preview))) ??
134
138
  thumbhashUri(shared(source, 'thumbhash', preview))
135
- // A thumbhash or blurDataUri in `placeholder` isn't an image to show as it
136
- // is: defaultSource still is.
139
+ // `placeholder` as a url (or a source with one) shows over the other
140
+ // placeholders as it loads, as on iOS and Android. The browser loads it
141
+ // like any image: it can't look in its cache only (cache: 'cacheOnly').
142
+ const urlPlaceholder = placeholderSource(placeholder)?.uri
143
+ const [failedPlaceholder, setFailedPlaceholder] = useState<string>()
144
+ // defaultSource only shows without `placeholder`, as on iOS and Android,
145
+ // or in place of a thumbhash or blurDataUri the web can't show (one of
146
+ // several sizes', or a thumbhash without react-native-fast-image/thumbhash,
147
+ // or an empty one). Not with a url (above).
137
148
  const defaultUri =
138
149
  blurUri ??
139
- resolveUri(preview ? defaultSource : (placeholder ?? defaultSource))
150
+ resolveUri(
151
+ preview
152
+ ? defaultSource
153
+ : urlPlaceholder
154
+ ? undefined
155
+ : (placeholder ?? defaultSource),
156
+ )
140
157
  // Whether the image for `key` loaded, or failed.
141
158
  const [result, setResult] = useState<{ key: string; ok: boolean }>()
142
159
  const ok = result && result.key === key ? result.ok : undefined
@@ -156,7 +173,7 @@ function FastImageBase({
156
173
  .filter(Boolean)
157
174
  .join(' ') || undefined
158
175
  // The placeholder shows until the image has loaded, and if it fails.
159
- const showDefault = !!defaultUri && ok !== true
176
+ const showDefault = ok !== true
160
177
 
161
178
  useClientLayoutEffect(() => {
162
179
  if (key) onLoadStart?.()
@@ -207,6 +224,28 @@ function FastImageBase({
207
224
  alt={key ? '' : (label ?? '')}
208
225
  />
209
226
  ) : null}
227
+ {urlPlaceholder && showDefault ? (
228
+ <Picture
229
+ key={`placeholder ${urlPlaceholder}`}
230
+ src={urlPlaceholder}
231
+ loading={loading}
232
+ // It only helps if it comes before the image, as on iOS
233
+ // and Android.
234
+ priority="high"
235
+ dynamicRangeLimit={dynamicRangeLimit}
236
+ // It stands for the image, as a source's preview does.
237
+ fit={
238
+ fit === 'scale-down' || fit === 'none' ? 'contain' : fit
239
+ }
240
+ position={position}
241
+ // Tinted, not blurred, as on iOS and Android.
242
+ filter={tintColor ? `url(#${tintId})` : undefined}
243
+ // Nothing (no broken image icon) if it fails.
244
+ hidden={failedPlaceholder === urlPlaceholder}
245
+ onError={() => setFailedPlaceholder(urlPlaceholder)}
246
+ alt=""
247
+ />
248
+ ) : null}
210
249
  {key && src ? (
211
250
  <Picture
212
251
  key={several ? 'sizes' : key}
@@ -306,14 +345,7 @@ function resolveUri(source: unknown): string | undefined {
306
345
  if (typeof source === 'number') {
307
346
  const asset: PackagerAsset | undefined = getAssetByID(source)
308
347
  if (!asset) return undefined
309
- const scale = asset.scales.reduce(
310
- (best, scale) =>
311
- Math.abs(scale - PixelRatio.get()) <
312
- Math.abs(best - PixelRatio.get())
313
- ? scale
314
- : best,
315
- asset.scales[0] ?? 1,
316
- )
348
+ const scale = assetScale(asset)
317
349
  const suffix = scale !== 1 ? `@${scale}x` : ''
318
350
  uri = `${asset.httpServerLocation}/${asset.name}${suffix}.${asset.type}`
319
351
  } else if (typeof source === 'string') {
@@ -335,6 +367,36 @@ type PackagerAsset = {
335
367
  name: string
336
368
  type: string
337
369
  scales: number[]
370
+ width?: number
371
+ height?: number
372
+ }
373
+
374
+ // The scale of an asset's file closest to the screen's.
375
+ function assetScale(asset: PackagerAsset): number {
376
+ return asset.scales.reduce(
377
+ (best, scale) =>
378
+ Math.abs(scale - PixelRatio.get()) <
379
+ Math.abs(best - PixelRatio.get())
380
+ ? scale
381
+ : best,
382
+ asset.scales[0] ?? 1,
383
+ )
384
+ }
385
+
386
+ // One of several sizes: a require()d image in react-native-web's asset
387
+ // registry as a source with its file's size (Expo's web builds make it a
388
+ // { uri, width, height } object already).
389
+ function sizedSource(source: Source | number): Source | undefined {
390
+ if (typeof source !== 'number') return source
391
+ const asset: PackagerAsset | undefined = getAssetByID(source)
392
+ return asset
393
+ ? {
394
+ uri: resolveUri(source),
395
+ width: asset.width,
396
+ height: asset.height,
397
+ scale: assetScale(asset),
398
+ }
399
+ : undefined
338
400
  }
339
401
 
340
402
  // Several sizes of an image, as a srcset (each size's width, times its
@@ -344,9 +406,10 @@ type PackagerAsset = {
344
406
  // is laid out at; it only applies to lazy images, and otherwise, or in
345
407
  // browsers without it, the next value, 100vw (the viewport's width). src is the largest, for browsers without
346
408
  // srcset.
347
- function sizedSources(sources: Source[]) {
409
+ function sizedSources(entries: (Source | number)[]) {
410
+ const sources = entries.map(sizedSource)
348
411
  const widths = sources
349
- .filter((source) => source?.uri && source.width)
412
+ .filter((source): source is Source => !!source?.uri && !!source.width)
350
413
  .map((source) => ({
351
414
  uri: source.uri as string,
352
415
  width: Math.round((source.width ?? 0) * (source.scale ?? 1)),
@@ -1,20 +1,37 @@
1
- // Placeholders given as strings (`placeholder`, and a source's `blurDataUri`)
2
- // are data uris, shared by the native (index.tsx) and web (index.web.tsx)
3
- // versions, or for `placeholder`, the name of an image bundled with the app.
4
- // They're decoded at once, so they show in the view's first frame; a url
5
- // would load like an image (and on iOS and Android, not at all), so it's
6
- // ignored, with a warning in development.
7
- import type { SourcePlaceholder } from './index'
1
+ // Placeholders given as strings, shared by the native (index.tsx) and web
2
+ // (index.web.tsx) versions: a source's `blurDataUri` is a data uri, decoded at
3
+ // once, so it shows in the view's first frame (a url there is ignored, with a
4
+ // warning in development). `placeholder` can also be the name of an image
5
+ // bundled with the app, or a url, loaded as a source (placeholderSource).
6
+ import type { PlaceholderSource, SourcePlaceholder } from './index'
8
7
  import { warnOnce } from './warnings'
9
8
 
10
9
  // Metro's (and React Native's Jest preset's) development flag; other web
11
10
  // bundlers may not define it.
12
11
  declare const __DEV__: boolean | undefined
13
12
 
13
+ const SCHEME = /^[a-z][a-z\d+.-]*:/i
14
+
14
15
  // Whether `placeholder` is the name of an image bundled with the app (an iOS
15
16
  // asset catalog image, an Android drawable): no scheme, not a path.
16
17
  export function isImageName(value: string): boolean {
17
- return !/^[a-z][a-z\d+.-]*:/i.test(value) && !value.startsWith('/')
18
+ return !SCHEME.test(value) && !value.startsWith('/')
19
+ }
20
+
21
+ // `placeholder` as a source to load: an http or https url, as a string or a
22
+ // source object's uri. Undefined for anything else (a local file or a data uri
23
+ // is decoded at once, as the native placeholder prop).
24
+ export function placeholderSource(
25
+ value: unknown,
26
+ ): PlaceholderSource | undefined {
27
+ const uri =
28
+ typeof value === 'string'
29
+ ? value
30
+ : value && typeof value === 'object'
31
+ ? (value as PlaceholderSource).uri
32
+ : undefined
33
+ if (typeof uri !== 'string' || !/^https?:/i.test(uri)) return undefined
34
+ return typeof value === 'string' ? { uri } : (value as PlaceholderSource)
18
35
  }
19
36
 
20
37
  // Whether a source has a thumbhash or blurDataUri, or `placeholder` is one
@@ -37,7 +54,7 @@ export function dataUri(value: unknown): string | undefined {
37
54
  if (typeof __DEV__ !== 'undefined' && __DEV__) {
38
55
  warnOnce(
39
56
  'placeholder-url',
40
- `react-native-fast-image: \`placeholder\` and \`blurDataUri\` don't load urls: "${value.slice(0, 60)}" is ignored. Use a data uri (data:image/…), or for \`placeholder\`, a require()d image or the name of an image bundled with the app.`,
57
+ `react-native-fast-image: \`blurDataUri\` takes a data uri (data:image/…): "${value.slice(0, 60)}" is ignored. For a placeholder loaded from a url, use \`placeholder\`.`,
41
58
  'warn',
42
59
  )
43
60
  }
@@ -40,19 +40,18 @@ type LoadEndEvent = Readonly<{
40
40
  }>
41
41
 
42
42
  export interface NativeProps extends ViewProps {
43
- // A source ({ uri, headers, priority, cache, ... }), as the native
44
- // sides read it.
43
+ // The `source` prop: a source ({ uri, headers, priority, cache, ... },
44
+ // with its thumbhash or blurDataUri), or several (sizes of one image) in
45
+ // an array, picked from for the view's size. A change to the thumbhashes
46
+ // and blurDataUris only doesn't load the image again.
45
47
  source?: CodegenTypes.UnsafeMixed
46
- // Several sources (sizes of one image), picked from for the view's size.
47
- sources?: CodegenTypes.UnsafeMixed
48
- // The `placeholder` prop: a resolved asset source ({ uri, width, height,
49
- // scale }) or a data uri ({ uri } on iOS; the uri on Android).
48
+ // The `placeholder` prop: a thumbhash or blurDataUri ({ thumbhash } or
49
+ // { blurDataUri }, for the sources without their own), a url to load
50
+ // apart from the image, as a source ({ uri, headers, cacheKey, cache }),
51
+ // or an image shown at once: a resolved asset source ({ uri, width,
52
+ // height, scale }) or a data uri ({ uri } on iOS; the uri on Android, as
53
+ // a string).
50
54
  placeholder?: CodegenTypes.UnsafeMixed
51
- // What shows in place of placeholder while a source loads: its ThumbHash,
52
- // or its blurDataUri (blurred natively), by the source's index ('' for
53
- // none; one for a single source). Apart from the sources, so a new one
54
- // for the same image doesn't load it again.
55
- sourcePlaceholders?: ReadonlyArray<string>
56
55
  // objectFit, resolved (fill, contain, cover, none or scale-down).
57
56
  objectFit?: CodegenTypes.WithDefault<string, 'cover'>
58
57
  // objectPosition, as CSS computes it: on each axis the image's left or top