@assetlib/sdk-expo 0.7.7-preview.1 → 0.7.8-preview.1

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/README.md CHANGED
@@ -36,7 +36,7 @@ Create a long-lived client outside repeated renders. After an explicit `refresh(
36
36
 
37
37
  ### Sizing
38
38
 
39
- A placement's width and height are a reference size: they fix the aspect ratio and identify the placement; the SDK sizes requests from the view. Without `pixelWidth` and `pixelHeight`, `AssetlibImage` and `AssetlibStateImage` request the view's measured layout (`onLayout`) × `PixelRatio.get()`, enough to cover the view (to fit it for `contentFit` `contain` or `scale-down`), rounded up to the console's rendition tiers: 512, 1024 or 2048 pixels on the long edge, or 24, 48, 72, 96, 192 or 512 for tintable icons. The request asks for 98% of the tier, so a rendition a few pixels short of its tier still serves it, and an unusual aspect ratio whose rendition falls further short gets the next larger one. A new view starts from the largest tier this client has measured for the placement, else from the reference size × `PixelRatio.get()`; its first layout replaces that reference-based estimate, so a first mount can resolve twice (`decide` and `onStatus` run again). After that a view's tier only grows, so rotating or resizing for split view never requests a smaller image; growing into a larger tier starts a new resolution, which shows the bundled fallback until the larger image is ready. Pass both `pixelWidth` and `pixelHeight` to request exact pixels instead. `AssetlibDynamicImage` keeps requesting its reference's own size unless you pass them.
39
+ A placement's width and height are a reference size: they fix the aspect ratio and identify the placement; the SDK sizes requests from the view. Without `pixelWidth` and `pixelHeight`, `AssetlibImage` and `AssetlibStateImage` request the view's measured layout (`onLayout`) × `PixelRatio.get()`, enough to cover the view (to fit it for `contentFit` `contain` or `scale-down`), rounded up to the console's rendition tiers: 512, 1024 or 2048 pixels on the long edge, or 24, 48, 72, 96, 192 or 512 for tintable icons. The request asks for 98% of the tier, so a rendition a few pixels short of its tier still serves it, and an unusual aspect ratio whose rendition falls further short gets the next larger one. A new view starts from the largest tier this client has measured for the placement. Without one, it draws its bundled fallback and waits for its first layout before it resolves, so a view whose first layout has a size resolves once. A first layout without a size resolves at the reference size × `PixelRatio.get()`, and the view's first sized layout then replaces that estimate. Once a measured layout has set a view's tier, it only grows, so rotating or resizing for split view never requests a smaller image; growing into a larger tier starts a new resolution, which shows the bundled fallback until the larger image is ready. Pass both `pixelWidth` and `pixelHeight` to request exact pixels instead. `AssetlibDynamicImage` keeps requesting its reference's own size unless you pass them.
40
40
 
41
41
  `onStatus` reports source selection and verification, including the effective `arm` (`null` for control) and `armSource` (`explicit`, `decision`, `control`, or `invalid-decision`). Initial bundle and renderer-failure statuses use `arm: null` and `armSource: 'control'`; resolved statuses preserve the core's decision diagnostics. This callback is not a user impression or proof that the image was visible. Do not send experiment exposure events from this callback. App layout, accessibility labels, and refresh policy remain app-owned. Rendering uses `expo-image` for downloaded PNG/WebP support on iOS and Android; props follow Expo Image except `cachePolicy`, which controls Assetlib's verified byte retention. The renderer's independent cache is always disabled. Pending resolution is cancelled when a component unmounts or changes its asset request.
42
42
 
@@ -239,7 +239,7 @@ await client.flush();
239
239
  client.dispose(); // Remove the AppState listener and stop observation timers.
240
240
  ```
241
241
 
242
- The adapter identifies itself as `sdk-expo` version `0.7.7-preview.1`. An explicit `telemetry.sdk` or `telemetry.build` takes precedence. When the runtime can optionally require `expo-application` or `expo-constants`, the adapter reads native app version and build number (falling back to Expo config version/build). Missing packages, unavailable runtime loaders, and throwing native bridges are ignored; no new dependency is required. Supply `build` explicitly for reliable production attribution, especially with bundlers that cannot resolve optional runtime requires or in Expo Go. Without complete runtime metadata or an explicit build, batches use platform `expo` and version/build `unknown`.
242
+ The adapter identifies itself as `sdk-expo` version `0.7.8-preview.1`. An explicit `telemetry.sdk` or `telemetry.build` takes precedence. When the runtime can optionally require `expo-application` or `expo-constants`, the adapter reads native app version and build number (falling back to Expo config version/build). Missing packages, unavailable runtime loaders, and throwing native bridges are ignored; no new dependency is required. Supply `build` explicitly for reliable production attribution, especially with bundlers that cannot resolve optional runtime requires or in Expo Go. Without complete runtime metadata or an explicit build, batches use platform `expo` and version/build `unknown`.
243
243
 
244
244
  Only the observation contract fields are sent: environment, SDK name/version, build platform/version/number, random install ID, send time, and coalesced events with placement key, kind/count, source, asset ID/release sequence when known, effective arm/appearance when selected, and fallback reason. Bundle outcomes send only `fallback` events without asset ID or sequence. Dynamic catalog references do not emit observations because they have no placement key. Image bytes, descriptions, URLs, device identifiers, account identifiers, and status/error messages are not included.
245
245
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assetlib/sdk-expo",
3
- "version": "0.7.7-preview.1",
3
+ "version": "0.7.8-preview.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./src/index.tsx",
@@ -20,7 +20,7 @@
20
20
  "test": "node --test test/*.test.mjs"
21
21
  },
22
22
  "dependencies": {
23
- "@assetlib/sdk-core": "0.7.7-preview.1"
23
+ "@assetlib/sdk-core": "0.7.8-preview.1"
24
24
  },
25
25
  "peerDependencies": {
26
26
  "expo": ">=54 <58",
package/src/index.tsx CHANGED
@@ -49,7 +49,7 @@ const ARTWORK_TIERS = [512, 1024, 2048];
49
49
  const ICON_TIERS = [24, 48, 72, 96, 192, 512];
50
50
  /** The largest tier measured per placement in this client's session; it seeds new mounts. */
51
51
  const sessionTiers = new WeakMap<AssetClient, Map<string, number>>();
52
- type MeasuredTier = { client: AssetClient; id: string; tier: number; measured: boolean };
52
+ type MeasuredTier = { client: AssetClient; id: string; tier: number; measured: boolean; laidOut: boolean };
53
53
 
54
54
  const tiersFor = (asset: AssetRef) => asset.rendering === 'template' ? ICON_TIERS : ARTWORK_TIERS;
55
55
  function tierFor(asset: AssetRef, longEdge: number): number {
@@ -82,8 +82,9 @@ function measuredTier(asset: AssetRef, layout: { width: number; height: number }
82
82
 
83
83
  /**
84
84
  * Without an explicit target, request the measured view × PixelRatio snapped to a tier. A mount starts
85
- * from the session's tier for this placement, else from the reference size × PixelRatio, which its first
86
- * layout replaces. After that only this view's own layout changes its tier, and only upward.
85
+ * from the session's tier for this placement; without one it is pending until its first layout, so it
86
+ * resolves once. A first layout without a size uses the reference size × PixelRatio until a sized layout
87
+ * replaces it. Once measured, only this view's own layout changes its tier, and only upward.
87
88
  */
88
89
  function usePixelTarget(client: AssetClient, asset: AssetRef, pixelWidth: number | undefined, pixelHeight: number | undefined, contentFit: ImageProps['contentFit'], onLayout: ImageProps['onLayout']) {
89
90
  const explicit = pixelWidth !== undefined || pixelHeight !== undefined;
@@ -92,21 +93,23 @@ function usePixelTarget(client: AssetClient, asset: AssetRef, pixelWidth: number
92
93
  sessionTiers.set(client, tiers);
93
94
  const seed = (): MeasuredTier => {
94
95
  const known = tiers.get(id);
95
- return { client, id, measured: known !== undefined, tier: known ?? tierFor(asset, Math.max(asset.width, asset.height) * displayScale()) };
96
+ return { client, id, measured: known !== undefined, laidOut: false, tier: known ?? tierFor(asset, Math.max(asset.width, asset.height) * displayScale()) };
96
97
  };
97
98
  const [stored, setStored] = useState(seed);
98
99
  let current = stored;
99
100
  if (stored.client !== client || stored.id !== id) setStored(current = seed());
100
101
  return {
101
102
  ...(explicit ? { pixelWidth, pixelHeight } : tierTarget(asset, current.tier)),
103
+ pending: !explicit && !current.measured && !current.laidOut,
102
104
  onLayout(event: Parameters<NonNullable<ImageProps['onLayout']>>[0]) {
103
105
  const measured = explicit ? undefined : measuredTier(asset, event.nativeEvent.layout, displayScale(), contentFit);
104
- if (measured !== undefined) {
105
- tiers.set(id, Math.max(tiers.get(id) ?? measured, measured));
106
+ if (measured !== undefined) tiers.set(id, Math.max(tiers.get(id) ?? measured, measured));
107
+ if (!explicit) {
106
108
  setStored(previous => {
107
109
  const base = previous.client === client && previous.id === id ? previous : current;
110
+ if (measured === undefined) return base.measured || base.laidOut ? base : { ...base, laidOut: true };
108
111
  const tier = base.measured ? Math.max(base.tier, measured) : measured;
109
- return base.measured && tier === base.tier ? base : { client, id, measured: true, tier };
112
+ return base.measured && tier === base.tier ? base : { client, id, measured: true, laidOut: true, tier };
110
113
  });
111
114
  }
112
115
  onLayout?.(event);
@@ -147,7 +150,7 @@ function matchesLoadedSource(event: Parameters<NonNullable<ImageProps['onLoad']>
147
150
  }
148
151
 
149
152
  /** A request token hides the previous asset immediately when a recycled view changes identity. */
150
- function useResolvedImage(client: AssetClient, asset: AssetRef | DynamicAssetRef, request: object, resolve: (signal: AbortSignal) => Promise<ResolvedAsset>, fallback: ImageSource, fallbackAccessibility: AssetAccessibility | undefined, requireDescription: boolean, onStatus?: (status: AssetStatus) => void) {
153
+ function useResolvedImage(client: AssetClient, asset: AssetRef | DynamicAssetRef, request: object, resolve: (signal: AbortSignal) => Promise<ResolvedAsset>, fallback: ImageSource, fallbackAccessibility: AssetAccessibility | undefined, requireDescription: boolean, onStatus?: (status: AssetStatus) => void, pending = false) {
151
154
  const [resolved, setResolved] = useState<{ request: object; image: ImageUri; result: ResolvedAsset; accessibility?: AssetAccessibility } | null>(null);
152
155
  const callbacks = useRef({ resolve, onStatus });
153
156
  callbacks.current = { resolve, onStatus };
@@ -156,6 +159,7 @@ function useResolvedImage(client: AssetClient, asset: AssetRef | DynamicAssetRef
156
159
  currentRequest.current = request;
157
160
  const displayed = useRef(false);
158
161
  useEffect(() => {
162
+ if (pending) { setResolved(null); return; }
159
163
  const controller = new AbortController();
160
164
  let localUri: ImageUri | null = null;
161
165
  setResolved(null);
@@ -183,7 +187,7 @@ function useResolvedImage(client: AssetClient, asset: AssetRef | DynamicAssetRef
183
187
  localUri?.release();
184
188
  if (currentUri.current === localUri) currentUri.current = null;
185
189
  };
186
- }, [client, request]);
190
+ }, [client, request, pending]);
187
191
  const isCurrent = () => resolved?.request === request && currentRequest.current === request && currentUri.current === resolved.image;
188
192
  const selected = resolved?.request === request ? resolved : null;
189
193
  const rendererKey = useRendererKey(request, selected?.image ?? fallback);
@@ -209,11 +213,11 @@ function useResolvedImage(client: AssetClient, asset: AssetRef | DynamicAssetRef
209
213
  export function AssetlibImage<R extends AssetRef>({ client, asset, arm, fallback, fallbackDark, appearance = 'system', fallbackAccessibility = asset.bundledAccessibility, accessibilityMode, accessibilityLocale, revision = 0, pixelWidth: requestedPixelWidth, pixelHeight: requestedPixelHeight, cachePolicy, onStatus, onError, onLoad, ...rest }: AssetlibImageProps<R>) {
210
214
  const props: Omit<ImageProps, 'source' | 'cachePolicy'> = rest;
211
215
  const effectiveAppearance = useEffectiveAppearance(appearance);
212
- const { pixelWidth, pixelHeight, onLayout } = usePixelTarget(client, asset, requestedPixelWidth, requestedPixelHeight, props.contentFit, props.onLayout);
216
+ const { pixelWidth, pixelHeight, pending, onLayout } = usePixelTarget(client, asset, requestedPixelWidth, requestedPixelHeight, props.contentFit, props.onLayout);
213
217
  useTemplateTintWarning(asset, props.tintColor);
214
218
  const requireDescription = accessibilityMode === 'description' && props.accessibilityLabel == null;
215
219
  const request = useMemo(() => ({}), [client, asset.key, asset.width, asset.height, asset.rendering, arm, revision, pixelWidth, pixelHeight, cachePolicy, requireDescription, effectiveAppearance]);
216
- const image = useResolvedImage(client, asset, request, signal => client.resolve(asset, { pixelWidth, pixelHeight, cachePolicy, appearance: effectiveAppearance, arm, signal }), effectiveAppearance === 'dark' ? fallbackDark ?? fallback : fallback, fallbackAccessibility, requireDescription, onStatus);
220
+ const image = useResolvedImage(client, asset, request, signal => client.resolve(asset, { pixelWidth, pixelHeight, cachePolicy, appearance: effectiveAppearance, arm, signal }), effectiveAppearance === 'dark' ? fallbackDark ?? fallback : fallback, fallbackAccessibility, requireDescription, onStatus, pending);
217
221
  return <Image {...props} key={image.rendererKey} {...accessibilityProps(props, accessibilityMode, image.accessibility, accessibilityLocale)} cachePolicy="none" source={image.source} onLayout={onLayout} onLoad={event => { image.loaded(event); onLoad?.(event); }} onError={event => { image.fail(); onError?.(event); }} />;
218
222
  }
219
223
 
@@ -230,7 +234,7 @@ export function AssetlibDynamicImage({ client, asset, fallback, fallbackDark, ap
230
234
  export function AssetlibStateImage<R extends StateSetRef>({ client, asset, arm, state, fallbacks, fallbacksDark, appearance = 'system', fallbackAccessibility = asset.bundledStateAccessibility, accessibilityMode, accessibilityLocale, revision = 0, pixelWidth: requestedPixelWidth, pixelHeight: requestedPixelHeight, cachePolicy, onStatus, onError, onLoad, ...rest }: AssetlibStateImageProps<R>) {
231
235
  const props: Omit<ImageProps, 'source' | 'cachePolicy'> = rest;
232
236
  const effectiveAppearance = useEffectiveAppearance(appearance);
233
- const { pixelWidth, pixelHeight, onLayout } = usePixelTarget(client, asset, requestedPixelWidth, requestedPixelHeight, props.contentFit, props.onLayout);
237
+ const { pixelWidth, pixelHeight, pending, onLayout } = usePixelTarget(client, asset, requestedPixelWidth, requestedPixelHeight, props.contentFit, props.onLayout);
234
238
  useTemplateTintWarning(asset, props.tintColor);
235
239
  const statesKey = JSON.stringify(asset.states);
236
240
  const requireDescription = accessibilityMode === 'description' && props.accessibilityLabel == null;
@@ -246,6 +250,7 @@ export function AssetlibStateImage<R extends StateSetRef>({ client, asset, arm,
246
250
  if (asset.states.some(name => !Object.prototype.hasOwnProperty.call(fallbacks, name) || fallbacks[name] == null)) throw new Error('Every artwork state requires its own bundled fallback.');
247
251
  if (fallbacksDark && asset.states.some(name => !Object.prototype.hasOwnProperty.call(fallbacksDark, name) || fallbacksDark[name] == null)) throw new Error('Every artwork state requires its own dark bundled fallback when fallbacksDark is supplied.');
248
252
  useEffect(() => {
253
+ if (pending) { setResolved(null); return; }
249
254
  const controller = new AbortController();
250
255
  const images: Record<string, ImageUri> = Object.create(null);
251
256
  const accessibility: Record<string, AssetAccessibility | undefined> = Object.create(null);
@@ -282,7 +287,7 @@ export function AssetlibStateImage<R extends StateSetRef>({ client, asset, arm,
282
287
  controller.abort(); release();
283
288
  if (currentUris.current === images) currentUris.current = null;
284
289
  };
285
- }, [client, request]);
290
+ }, [client, request, pending]);
286
291
  const selected = resolved?.request === request ? resolved.images[state] : null;
287
292
  const metadata = selected ? resolved!.accessibility[state] : fallbackAccessibility?.[state];
288
293
  const fallback = (effectiveAppearance === 'dark' ? fallbacksDark ?? fallbacks : fallbacks)[state];
package/src/telemetry.ts CHANGED
@@ -48,7 +48,7 @@ export function expoTelemetry(telemetry: ClientOptions['telemetry']): ClientOpti
48
48
  if (!telemetry?.enabled) return telemetry;
49
49
  return {
50
50
  ...telemetry,
51
- sdk: telemetry.sdk ?? { name: 'sdk-expo', version: '0.7.7-preview.1' },
51
+ sdk: telemetry.sdk ?? { name: 'sdk-expo', version: '0.7.8-preview.1' },
52
52
  build: telemetry.build ?? runtimeBuild() ?? { platform: 'expo', appVersion: 'unknown', buildNumber: 'unknown' },
53
53
  };
54
54
  }