@assetlib/sdk-expo 0.7.6-preview.1 → 0.7.7-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
@@ -26,8 +26,6 @@ await client.refresh(); // Explicit network check.
26
26
  asset={AppAssets.Travel.coast}
27
27
  fallback={require('./assets/coast.png')}
28
28
  revision={refreshCounter}
29
- pixelWidth={600}
30
- pixelHeight={450}
31
29
  style={{ width: '100%', aspectRatio: 4 / 3 }}
32
30
  contentFit="cover"
33
31
  onStatus={(status) => console.log(status.source, status.sequence)}
@@ -36,7 +34,11 @@ await client.refresh(); // Explicit network check.
36
34
 
37
35
  Create a long-lived client outside repeated renders. After an explicit `refresh()`, increment `revision` to request the current artwork. `AssetlibImage` initially displays the bundled fallback, then a verified local image. A native/browser image decode error returns to the bundled image. Supply a stable `fallback` value, such as React Native's numeric `require()` result.
38
36
 
39
- `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, image sizing, 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.
37
+ ### Sizing
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.
40
+
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.
40
42
 
41
43
  ## Appearance variants
42
44
 
@@ -72,7 +74,7 @@ Declare `"rendering": "template"` on a catalog placement and regenerate its refe
72
74
 
73
75
  - TypeScript requires `tintColor` when `asset` is a generated template reference. At runtime, a template reference without `tintColor` logs one development warning per placement key.
74
76
  - The bundled fallback must be a single-color shape too, such as a PNG with alpha.
75
- - Without `pixelWidth` and `pixelHeight`, a template reference requests the placement's logical size × `PixelRatio.get()`, rounded up, so small icons stay sharp. Other references keep requesting the logical size.
77
+ - Without `pixelWidth` and `pixelHeight`, a template reference requests the measured view × `PixelRatio.get()` on the icon tiers ([Sizing](#sizing)), so small icons stay sharp.
76
78
  - If the published artwork's rendering does not match the reference, the component keeps the bundled fallback without downloading (see [tintable icons](../sdk-core/README.md#tintable-icons) in the core guide).
77
79
 
78
80
  ## Arms and app decisions
@@ -188,7 +190,7 @@ Caches and durable replay state are isolated by manifest origin, organization, a
188
190
 
189
191
  For the first upgrade from the old namespace scheme, retain the previous pinned set until migration has completed. Migration recognizes subsets of the current pins, including an expansion during upgrade, but cannot reconstruct an old sorted-set namespace containing a removed, unused pin from its one-way hash. Searches are limited to 4,096 namespace probes; exceeding this limit fails closed with bundled artwork instead of resetting replay protection. After migration, pin changes do not change storage identity; every retained release must still verify with the current pins.
190
192
 
191
- There is no automatic app-data reset API: disconnect the client to show bundled artwork while retaining the cache. Explicitly clearing app/browser data removes stored replay protection. Multiple native clients are serialized within one JavaScript process; cross-process synchronization is outside this preview's contract. Preview 0.2 supports PNG/WebP renditions selected by explicit pixel size. Logical placement dimensions still match the generated catalog. Native caches preserve compatibility with existing `.webp` entries. Web can opt into normalized SVG with `createExpoAssetClient(config, { allowVector: true })`; enabling that option on native throws. On native, `client.resolveAnimation(ref)` resolves verified Lottie JSON and, under the `disk` policy, caches it as `<sha256>.json` beside the cached images (at most 512 KiB per entry, inside the same budget) for a Lottie player that the app supplies. The SDK includes no player, and its image components show the still poster. Native SVG and PDF delivery are not implemented. Browser rendering checks decoded dimensions before handing the image to Expo; native Expo decode errors fall back to the bundled image. See the core README for signature, timeout, body-size, and compatibility limits.
193
+ There is no automatic app-data reset API: disconnect the client to show bundled artwork while retaining the cache. Explicitly clearing app/browser data removes stored replay protection. Multiple native clients are serialized within one JavaScript process; cross-process synchronization is outside this preview's contract. PNG/WebP renditions are selected by requested pixel size ([Sizing](#sizing)); the placement's reference dimensions still match the generated catalog. Native caches preserve compatibility with existing `.webp` entries. Web can opt into normalized SVG with `createExpoAssetClient(config, { allowVector: true })`; enabling that option on native throws. On native, `client.resolveAnimation(ref)` resolves verified Lottie JSON and, under the `disk` policy, caches it as `<sha256>.json` beside the cached images (at most 512 KiB per entry, inside the same budget) for a Lottie player that the app supplies. The SDK includes no player, and its image components show the still poster. Native SVG and PDF delivery are not implemented. Browser rendering checks decoded dimensions before handing the image to Expo; native Expo decode errors fall back to the bundled image. See the core README for signature, timeout, body-size, and compatibility limits.
192
194
 
193
195
  ## Short looping video
194
196
 
@@ -237,7 +239,7 @@ await client.flush();
237
239
  client.dispose(); // Remove the AppState listener and stop observation timers.
238
240
  ```
239
241
 
240
- The adapter identifies itself as `sdk-expo` version `0.7.6-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.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`.
241
243
 
242
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.
243
245
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assetlib/sdk-expo",
3
- "version": "0.7.6-preview.1",
3
+ "version": "0.7.7-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.6-preview.1"
23
+ "@assetlib/sdk-core": "0.7.7-preview.1"
24
24
  },
25
25
  "peerDependencies": {
26
26
  "expo": ">=54 <58",
package/src/index.tsx CHANGED
@@ -44,12 +44,74 @@ function useEffectiveAppearance(appearance: 'light' | 'dark' | 'system') {
44
44
  return systemAppearance === 'light' || systemAppearance === 'dark' ? systemAppearance : undefined;
45
45
  }
46
46
 
47
- /** Template masks are rasters: without an explicit target, request logical size × display scale. */
48
- function pixelTarget(asset: AssetRef, pixelWidth?: number, pixelHeight?: number): { pixelWidth?: number; pixelHeight?: number } {
49
- if (asset.rendering !== 'template' || pixelWidth !== undefined || pixelHeight !== undefined) return { pixelWidth, pixelHeight };
47
+ // The console's rendition tiers (long edge in pixels): artwork, then tintable icon masks.
48
+ const ARTWORK_TIERS = [512, 1024, 2048];
49
+ const ICON_TIERS = [24, 48, 72, 96, 192, 512];
50
+ /** The largest tier measured per placement in this client's session; it seeds new mounts. */
51
+ const sessionTiers = new WeakMap<AssetClient, Map<string, number>>();
52
+ type MeasuredTier = { client: AssetClient; id: string; tier: number; measured: boolean };
53
+
54
+ const tiersFor = (asset: AssetRef) => asset.rendering === 'template' ? ICON_TIERS : ARTWORK_TIERS;
55
+ function tierFor(asset: AssetRef, longEdge: number): number {
56
+ const tiers = tiersFor(asset);
57
+ // Half a pixel absorbs float layout values, such as 341.333 points at 3x.
58
+ const index = tiers.findIndex(tier => tier + 0.5 >= longEdge);
59
+ return index < 0 ? tiers.length - 1 : index;
60
+ }
61
+ function displayScale(): number {
50
62
  const ratio = PixelRatio.get();
51
- const scale = Number.isFinite(ratio) && ratio > 0 ? ratio : 1;
52
- return { pixelWidth: Math.min(8192, Math.ceil(asset.width * scale)), pixelHeight: Math.min(8192, Math.ceil(asset.height * scale)) };
63
+ return Number.isFinite(ratio) && ratio > 0 ? ratio : 1;
64
+ }
65
+
66
+ /**
67
+ * 98% of the tier: core picks the tier's rendition when an integer aspect ratio leaves it a few
68
+ * pixels short, and the next larger one when an unusual ratio leaves it further short.
69
+ */
70
+ function tierTarget(asset: AssetRef, tier: number): { pixelWidth: number; pixelHeight: number } {
71
+ const longEdge = Math.floor(tiersFor(asset)[tier] * 0.98);
72
+ const long = Math.max(asset.width, asset.height);
73
+ return { pixelWidth: Math.max(1, Math.ceil(longEdge * asset.width / long)), pixelHeight: Math.max(1, Math.ceil(longEdge * asset.height / long)) };
74
+ }
75
+
76
+ /** The tier a view needs: its layout × display scale, enough to cover the box (or fit it for contain). */
77
+ function measuredTier(asset: AssetRef, layout: { width: number; height: number }, ratio: number, contentFit?: ImageProps['contentFit']): number | undefined {
78
+ if (!(layout.width > 0 && layout.height > 0 && Number.isFinite(layout.width) && Number.isFinite(layout.height))) return undefined;
79
+ const fit = contentFit === 'contain' || contentFit === 'scale-down' ? Math.min : Math.max;
80
+ return tierFor(asset, fit(layout.width / asset.width, layout.height / asset.height) * Math.max(asset.width, asset.height) * ratio);
81
+ }
82
+
83
+ /**
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.
87
+ */
88
+ function usePixelTarget(client: AssetClient, asset: AssetRef, pixelWidth: number | undefined, pixelHeight: number | undefined, contentFit: ImageProps['contentFit'], onLayout: ImageProps['onLayout']) {
89
+ const explicit = pixelWidth !== undefined || pixelHeight !== undefined;
90
+ const id = `${asset.key}:${asset.width}x${asset.height}:${asset.rendering ?? 'original'}`;
91
+ const tiers = sessionTiers.get(client) ?? new Map<string, number>();
92
+ sessionTiers.set(client, tiers);
93
+ const seed = (): MeasuredTier => {
94
+ const known = tiers.get(id);
95
+ return { client, id, measured: known !== undefined, tier: known ?? tierFor(asset, Math.max(asset.width, asset.height) * displayScale()) };
96
+ };
97
+ const [stored, setStored] = useState(seed);
98
+ let current = stored;
99
+ if (stored.client !== client || stored.id !== id) setStored(current = seed());
100
+ return {
101
+ ...(explicit ? { pixelWidth, pixelHeight } : tierTarget(asset, current.tier)),
102
+ onLayout(event: Parameters<NonNullable<ImageProps['onLayout']>>[0]) {
103
+ 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
+ setStored(previous => {
107
+ const base = previous.client === client && previous.id === id ? previous : current;
108
+ const tier = base.measured ? Math.max(base.tier, measured) : measured;
109
+ return base.measured && tier === base.tier ? base : { client, id, measured: true, tier };
110
+ });
111
+ }
112
+ onLayout?.(event);
113
+ },
114
+ };
53
115
  }
54
116
 
55
117
  const tintWarnings = new Set<string>();
@@ -147,12 +209,12 @@ function useResolvedImage(client: AssetClient, asset: AssetRef | DynamicAssetRef
147
209
  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>) {
148
210
  const props: Omit<ImageProps, 'source' | 'cachePolicy'> = rest;
149
211
  const effectiveAppearance = useEffectiveAppearance(appearance);
150
- const { pixelWidth, pixelHeight } = pixelTarget(asset, requestedPixelWidth, requestedPixelHeight);
212
+ const { pixelWidth, pixelHeight, onLayout } = usePixelTarget(client, asset, requestedPixelWidth, requestedPixelHeight, props.contentFit, props.onLayout);
151
213
  useTemplateTintWarning(asset, props.tintColor);
152
214
  const requireDescription = accessibilityMode === 'description' && props.accessibilityLabel == null;
153
215
  const request = useMemo(() => ({}), [client, asset.key, asset.width, asset.height, asset.rendering, arm, revision, pixelWidth, pixelHeight, cachePolicy, requireDescription, effectiveAppearance]);
154
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);
155
- return <Image {...props} key={image.rendererKey} {...accessibilityProps(props, accessibilityMode, image.accessibility, accessibilityLocale)} cachePolicy="none" source={image.source} onLoad={event => { image.loaded(event); onLoad?.(event); }} onError={event => { image.fail(); onError?.(event); }} />;
217
+ 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); }} />;
156
218
  }
157
219
 
158
220
  /** Runtime references come from this client's verified collection page, never from an arbitrary URL. */
@@ -168,7 +230,7 @@ export function AssetlibDynamicImage({ client, asset, fallback, fallbackDark, ap
168
230
  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>) {
169
231
  const props: Omit<ImageProps, 'source' | 'cachePolicy'> = rest;
170
232
  const effectiveAppearance = useEffectiveAppearance(appearance);
171
- const { pixelWidth, pixelHeight } = pixelTarget(asset, requestedPixelWidth, requestedPixelHeight);
233
+ const { pixelWidth, pixelHeight, onLayout } = usePixelTarget(client, asset, requestedPixelWidth, requestedPixelHeight, props.contentFit, props.onLayout);
172
234
  useTemplateTintWarning(asset, props.tintColor);
173
235
  const statesKey = JSON.stringify(asset.states);
174
236
  const requireDescription = accessibilityMode === 'description' && props.accessibilityLabel == null;
@@ -226,7 +288,7 @@ export function AssetlibStateImage<R extends StateSetRef>({ client, asset, arm,
226
288
  const fallback = (effectiveAppearance === 'dark' ? fallbacksDark ?? fallbacks : fallbacks)[state];
227
289
  const rendererKey = useRendererKey(request, selected ?? fallback, state);
228
290
  const isCurrent = () => selected && currentSelection.current.request === request && currentSelection.current.state === state && currentUris.current === resolved?.images;
229
- return <Image {...props} key={rendererKey} {...accessibilityProps(props, accessibilityMode, metadata, accessibilityLocale)} cachePolicy="none" source={selected ? { uri: selected.uri } : fallback} onLoad={event => {
291
+ return <Image {...props} key={rendererKey} {...accessibilityProps(props, accessibilityMode, metadata, accessibilityLocale)} cachePolicy="none" source={selected ? { uri: selected.uri } : fallback} onLayout={onLayout} onLoad={event => {
230
292
  if (isCurrent() && !displayed.current && resolved!.results[state].source !== 'bundle' && matchesLoadedSource(event, selected!.uri)) {
231
293
  displayed.current = true;
232
294
  client.reportDisplay(asset, resolved!.results[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.6-preview.1' },
51
+ sdk: telemetry.sdk ?? { name: 'sdk-expo', version: '0.7.7-preview.1' },
52
52
  build: telemetry.build ?? runtimeBuild() ?? { platform: 'expo', appVersion: 'unknown', buildNumber: 'unknown' },
53
53
  };
54
54
  }