@webority/mobile-ui 0.0.37 → 0.0.39

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
@@ -10,7 +10,7 @@ npm install @webority/mobile-ui
10
10
 
11
11
  No external runtime dependencies beyond `@webority/mobile-core`. Requires
12
12
  `react-native-gesture-handler`, `react-native-reanimated` and `react-native-safe-area-context` as
13
- peers; `react-native-haptic-feedback` is optional. Gradients use React Native's own
13
+ peers, and `expo-file-system`, where `AuthedImage` keeps its downloaded images; `react-native-haptic-feedback` is optional. Gradients use React Native's own
14
14
  `experimental_backgroundImage`, so no gradient library is needed.
15
15
 
16
16
  Full documentation lives in the repository's `docs/` directory.
@@ -82,10 +82,10 @@ const AuthedImage = props => {
82
82
  return;
83
83
  }
84
84
  if (cached && (0, _authedImageCache.isFresh)(cached.meta, ttlMs)) {
85
- void (0, _authedImageCache.touchEntry)(key, cached.meta);
85
+ void (0, _authedImageCache.touchEntry)(key);
86
86
  finish({
87
87
  status: 'ready',
88
- dataUri: cached.dataUri
88
+ fileUri: cached.fileUri
89
89
  });
90
90
  return;
91
91
  }
@@ -103,7 +103,7 @@ const AuthedImage = props => {
103
103
  const result = await entry.promise;
104
104
  finish({
105
105
  status: 'ready',
106
- dataUri: result.dataUri
106
+ fileUri: result.fileUri
107
107
  });
108
108
  } catch (error) {
109
109
  if (cancelled) {
@@ -114,7 +114,7 @@ const AuthedImage = props => {
114
114
  if (cached) {
115
115
  finish({
116
116
  status: 'ready',
117
- dataUri: cached.dataUri
117
+ fileUri: cached.fileUri
118
118
  });
119
119
  return;
120
120
  }
@@ -191,7 +191,7 @@ const AuthedImage = props => {
191
191
  testID: testID,
192
192
  style: style,
193
193
  source: {
194
- uri: state.dataUri
194
+ uri: state.fileUri
195
195
  }
196
196
  });
197
197
  };
@@ -5,11 +5,21 @@ Object.defineProperty(exports, "__esModule", {
5
5
  });
6
6
  exports.touchEntry = exports.setAuthedImageCacheLimit = exports.releaseFetch = exports.readEntry = exports.isFresh = exports.getAuthedImageCacheLimit = exports.deriveCacheKey = exports.clearAuthedImageCache = exports.authedImageCacheStats = exports.acquireFetch = exports.DEFAULT_TTL_MS = void 0;
7
7
  var _mobileCore = require("@webority/mobile-core");
8
+ var _expoFileSystem = require("expo-file-system");
8
9
  var _hashString = require("../../utils/hashString.js");
9
- const KEY_PREFIX = '@webority/authed-image/v1/';
10
- const DATA_PREFIX = `${KEY_PREFIX}d/`;
11
- const META_PREFIX = `${KEY_PREFIX}m/`;
10
+ const INDEX_KEY = '@webority/authed-image/v2/index';
11
+ // Releases before the file cache kept every image as a base64 string in the
12
+ // key-value store, up to the 50 MB ceiling. Nothing reads those rows any more,
13
+ // so they are removed the first time this version finds no index.
14
+ const LEGACY_PREFIX = '@webority/authed-image/v1/';
15
+ const FOLDER = 'authed-images';
12
16
  const DEFAULT_BYTE_CEILING = 50 * 1024 * 1024;
17
+
18
+ // A hit only moves an entry's last access in memory. Writing the index on every
19
+ // hit would put a storage write behind every row of a scrolling list, so a hit
20
+ // persists only when the stored time is this old; least-recently-used order
21
+ // survives a restart to within this window.
22
+ const TOUCH_PERSIST_MS = 60 * 60 * 1000;
13
23
  const DEFAULT_TTL_MS = exports.DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000;
14
24
  let byteCeiling = DEFAULT_BYTE_CEILING;
15
25
 
@@ -28,135 +38,196 @@ exports.setAuthedImageCacheLimit = setAuthedImageCacheLimit;
28
38
  const getAuthedImageCacheLimit = () => byteCeiling;
29
39
 
30
40
  /**
31
- * Hex only by construction, so it is always a safe storage key segment.
41
+ * Hex only by construction, so it is always a safe file name segment.
32
42
  */
33
43
  exports.getAuthedImageCacheLimit = getAuthedImageCacheLimit;
34
44
  const deriveCacheKey = (uri, override) => (0, _hashString.hashString)(override ?? uri);
35
45
  exports.deriveCacheKey = deriveCacheKey;
36
- const B64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
37
- const encodeBase64 = buffer => {
38
- const bytes = new Uint8Array(buffer);
39
- let out = '';
40
- for (let i = 0; i < bytes.length; i += 3) {
41
- const b0 = bytes[i];
42
- const b1 = i + 1 < bytes.length ? bytes[i + 1] : undefined;
43
- const b2 = i + 2 < bytes.length ? bytes[i + 2] : undefined;
44
- out += B64_ALPHABET[b0 >> 2];
45
- out += B64_ALPHABET[(b0 & 0x03) << 4 | (b1 ?? 0) >> 4];
46
- out += b1 === undefined ? '=' : B64_ALPHABET[((b1 & 0x0f) << 2 | (b2 ?? 0) >> 6) & 0x3f];
47
- out += b2 === undefined ? '=' : B64_ALPHABET[b2 & 0x3f];
46
+ const imageFile = fileName => new _expoFileSystem.File(_expoFileSystem.Paths.cache, FOLDER, fileName);
47
+ const toMeta = ({
48
+ fetchedAt,
49
+ bytes,
50
+ contentType,
51
+ lastAccessedAt
52
+ }) => ({
53
+ fetchedAt,
54
+ bytes,
55
+ contentType,
56
+ lastAccessedAt
57
+ });
58
+
59
+ // The image bytes live in files; only this small index goes to the key-value
60
+ // store. A store that fails costs persistence across restarts, never an image:
61
+ // Storage logs and returns false rather than throwing.
62
+ let index = null;
63
+ let indexLoad = null;
64
+ const persistIndex = async entries => {
65
+ if (!(0, _mobileCore.isStorageAvailable)()) {
66
+ return;
67
+ }
68
+ const out = {};
69
+ for (const [cacheKey, entry] of entries) {
70
+ out[cacheKey] = {
71
+ ...entry,
72
+ persistedAccessAt: entry.lastAccessedAt
73
+ };
74
+ }
75
+ if (await _mobileCore.Storage.setItem(INDEX_KEY, JSON.stringify(out))) {
76
+ for (const entry of entries.values()) {
77
+ entry.persistedAccessAt = entry.lastAccessedAt;
78
+ }
79
+ }
80
+ };
81
+ const removeLegacyEntries = async () => {
82
+ const keys = await _mobileCore.Storage.getAllKeys();
83
+ const legacy = keys.filter(key => key.startsWith(LEGACY_PREFIX));
84
+ if (legacy.length > 0) {
85
+ await _mobileCore.Storage.multiRemove(legacy);
48
86
  }
49
- return out;
50
87
  };
51
88
 
52
89
  /**
53
- * The cache is an OPTIMISATION, so a failing store must never stop an image
54
- * rendering. That used to mean guarding on whether a backend existed at all,
55
- * warning once and skipping the cache; under Expo-first the backend ships with
56
- * the library, so "no backend" is no longer a state an app can be in and that
57
- * branch was unreachable.
58
- *
59
- * What remains is a store that exists and FAILS, which the per-operation error
60
- * handling below already covers: each read returns null and each write returns
61
- * false, the image still fetches, and nothing is logged per image.
90
+ * A file the index does not name is unreachable: written just before the app
91
+ * died, or left by an index write that failed. Removing them here, once per
92
+ * session and before anything new is written, keeps the byte ceiling true.
62
93
  */
63
- const cacheUsable = () => (0, _mobileCore.isStorageAvailable)();
64
- const readEntry = async cacheKey => {
65
- if (!cacheUsable()) {
66
- return null;
94
+ const removeOrphanFiles = entries => {
95
+ try {
96
+ const folder = new _expoFileSystem.Directory(_expoFileSystem.Paths.cache, FOLDER);
97
+ if (!folder.exists) {
98
+ return;
99
+ }
100
+ const known = new Set([...entries.values()].map(entry => imageFile(entry.fileName).uri));
101
+ for (const item of folder.list()) {
102
+ if (!known.has(item.uri)) {
103
+ item.delete();
104
+ }
105
+ }
106
+ } catch (error) {
107
+ _mobileCore.Logger.warn('[AuthedImage] could not tidy the image cache folder', error);
67
108
  }
68
- const values = await _mobileCore.Storage.multiGet([`${DATA_PREFIX}${cacheKey}`, `${META_PREFIX}${cacheKey}`]);
69
- const dataUri = values[`${DATA_PREFIX}${cacheKey}`];
70
- const rawMeta = values[`${META_PREFIX}${cacheKey}`];
71
- if (!dataUri || !rawMeta) {
72
- return null;
109
+ };
110
+ const readStoredIndex = async () => {
111
+ const entries = new Map();
112
+ if (!(0, _mobileCore.isStorageAvailable)()) {
113
+ return entries;
114
+ }
115
+ const raw = await _mobileCore.Storage.getItem(INDEX_KEY);
116
+ if (!raw) {
117
+ await removeLegacyEntries();
118
+ return entries;
73
119
  }
74
120
  try {
75
- return {
76
- dataUri,
77
- meta: JSON.parse(rawMeta)
78
- };
121
+ for (const [cacheKey, entry] of Object.entries(JSON.parse(raw))) {
122
+ entries.set(cacheKey, entry);
123
+ }
79
124
  } catch {
125
+ // An index we cannot parse names no file, so every file becomes an orphan
126
+ // and is removed below: the cache starts empty rather than wrong.
127
+ }
128
+ return entries;
129
+ };
130
+ const loadIndex = () => {
131
+ if (index) {
132
+ return Promise.resolve(index);
133
+ }
134
+ if (!indexLoad) {
135
+ indexLoad = readStoredIndex().then(entries => {
136
+ removeOrphanFiles(entries);
137
+ index = entries;
138
+ return entries;
139
+ }).finally(() => {
140
+ indexLoad = null;
141
+ });
142
+ }
143
+ return indexLoad;
144
+ };
145
+ const deleteFile = fileName => {
146
+ try {
147
+ const file = imageFile(fileName);
148
+ if (file.exists) {
149
+ file.delete();
150
+ }
151
+ } catch (error) {
152
+ _mobileCore.Logger.warn('[AuthedImage] could not delete a cached image file', error);
153
+ }
154
+ };
155
+ const readEntry = async cacheKey => {
156
+ const entries = await loadIndex();
157
+ const entry = entries.get(cacheKey);
158
+ if (!entry) {
159
+ return null;
160
+ }
161
+ const file = imageFile(entry.fileName);
162
+ // The cache folder is the OS's to reclaim under storage pressure, so an
163
+ // entry can outlive its file.
164
+ if (!file.exists) {
165
+ entries.delete(cacheKey);
166
+ await persistIndex(entries);
80
167
  return null;
81
168
  }
169
+ return {
170
+ fileUri: file.uri,
171
+ meta: toMeta(entry)
172
+ };
82
173
  };
83
174
  exports.readEntry = readEntry;
84
175
  const isFresh = (meta, ttlMs, now = Date.now()) => now - meta.fetchedAt < ttlMs;
85
176
  exports.isFresh = isFresh;
86
- const touchEntry = async (cacheKey, meta) => {
87
- if (!cacheUsable()) {
177
+ const touchEntry = async cacheKey => {
178
+ const entries = await loadIndex();
179
+ const entry = entries.get(cacheKey);
180
+ if (!entry) {
88
181
  return;
89
182
  }
90
- await _mobileCore.Storage.setItem(`${META_PREFIX}${cacheKey}`, JSON.stringify({
91
- ...meta,
92
- lastAccessedAt: Date.now()
93
- }));
183
+ entry.lastAccessedAt = Date.now();
184
+ if (entry.lastAccessedAt - entry.persistedAccessAt >= TOUCH_PERSIST_MS) {
185
+ await persistIndex(entries);
186
+ }
94
187
  };
95
188
  exports.touchEntry = touchEntry;
96
- const listMeta = async () => {
97
- if (!cacheUsable()) {
98
- return [];
99
- }
100
- const keys = await _mobileCore.Storage.getAllKeys();
101
- const metaKeys = keys.filter(key => key.startsWith(META_PREFIX));
102
- if (metaKeys.length === 0) {
103
- return [];
104
- }
105
- const values = await _mobileCore.Storage.multiGet(metaKeys);
106
- const out = [];
107
- for (const key of metaKeys) {
108
- const raw = values[key];
109
- if (!raw) {
110
- continue;
111
- }
112
- try {
113
- out.push({
114
- cacheKey: key.slice(META_PREFIX.length),
115
- meta: JSON.parse(raw)
116
- });
117
- } catch {
118
- // A meta entry we cannot parse is unusable; dropping it from the ledger
119
- // lets the next eviction pass remove its orphaned data entry.
120
- }
189
+ const evictIfNeeded = entries => {
190
+ let total = 0;
191
+ for (const entry of entries.values()) {
192
+ total += entry.bytes;
121
193
  }
122
- return out;
123
- };
124
- const evictIfNeeded = async () => {
125
- const entries = await listMeta();
126
- let total = entries.reduce((sum, entry) => sum + entry.meta.bytes, 0);
127
194
  if (total <= byteCeiling) {
128
- return;
195
+ return 0;
129
196
  }
130
- const ordered = [...entries].sort((a, b) => a.meta.lastAccessedAt - b.meta.lastAccessedAt);
131
- const doomed = [];
132
- for (const entry of ordered) {
197
+ const ordered = [...entries].sort(([, a], [, b]) => a.lastAccessedAt - b.lastAccessedAt);
198
+ let evicted = 0;
199
+ for (const [cacheKey, entry] of ordered) {
133
200
  if (total <= byteCeiling) {
134
201
  break;
135
202
  }
136
- doomed.push(`${DATA_PREFIX}${entry.cacheKey}`, `${META_PREFIX}${entry.cacheKey}`);
137
- total -= entry.meta.bytes;
138
- }
139
- if (doomed.length > 0) {
140
- await _mobileCore.Storage.multiRemove(doomed);
141
- _mobileCore.Logger.info(`[AuthedImage] evicted ${doomed.length / 2} cached image(s) over the byte ceiling`);
203
+ entries.delete(cacheKey);
204
+ deleteFile(entry.fileName);
205
+ total -= entry.bytes;
206
+ evicted += 1;
142
207
  }
208
+ return evicted;
143
209
  };
144
210
  const clearAuthedImageCache = async () => {
145
- if (!cacheUsable()) {
146
- return;
211
+ const entries = await loadIndex();
212
+ entries.clear();
213
+ const folder = new _expoFileSystem.Directory(_expoFileSystem.Paths.cache, FOLDER);
214
+ if (folder.exists) {
215
+ folder.delete();
147
216
  }
148
- const keys = await _mobileCore.Storage.getAllKeys();
149
- const ours = keys.filter(key => key.startsWith(KEY_PREFIX));
150
- if (ours.length > 0) {
151
- await _mobileCore.Storage.multiRemove(ours);
217
+ if ((0, _mobileCore.isStorageAvailable)()) {
218
+ await _mobileCore.Storage.removeItem(INDEX_KEY);
152
219
  }
153
220
  };
154
221
  exports.clearAuthedImageCache = clearAuthedImageCache;
155
222
  const authedImageCacheStats = async () => {
156
- const entries = await listMeta();
223
+ const entries = await loadIndex();
224
+ let bytes = 0;
225
+ for (const entry of entries.values()) {
226
+ bytes += entry.bytes;
227
+ }
157
228
  return {
158
- entries: entries.length,
159
- bytes: entries.reduce((sum, entry) => sum + entry.meta.bytes, 0)
229
+ entries: entries.size,
230
+ bytes
160
231
  };
161
232
  };
162
233
  exports.authedImageCacheStats = authedImageCacheStats;
@@ -194,29 +265,48 @@ const fetchAndStore = async (cacheKey, options, signal) => {
194
265
  }
195
266
  throw new Error(`[@webority/mobile-ui] AuthedImage could not load "${options.uri}": the server ` + `answered ${status}. Check the URL and whether the request needs to be signed in ` + '(pass the signed-in client as `client`, or authenticated={false} for public media).');
196
267
  }
197
- const buffer = response.data ?? new ArrayBuffer(0);
268
+ const bytes = new Uint8Array(response.data ?? new ArrayBuffer(0));
198
269
  const rawType = responseHeader(response.headers, 'content-type');
199
270
  const contentType = (rawType ? rawType.split(';')[0].trim() : '') || 'image/jpeg';
200
- const dataUri = `data:${contentType};base64,${encodeBase64(buffer)}`;
271
+
272
+ // Loaded before the write, so the orphan sweep in loadIndex can never see
273
+ // this file before its entry exists.
274
+ const entries = await loadIndex();
201
275
  const now = Date.now();
202
- // `bytes` measures the stored data URI, not the decoded image: the ceiling
203
- // exists to bound what sits in storage, and base64 costs about a third more.
204
- const meta = {
276
+ // A new name per fetch: native image caches key on the URI, so rewriting one
277
+ // path in place could keep showing the old picture after a refresh.
278
+ const fileName = `${cacheKey}-${now.toString(36)}`;
279
+ const file = imageFile(fileName);
280
+ try {
281
+ file.create({
282
+ intermediates: true,
283
+ overwrite: true
284
+ });
285
+ file.write(bytes);
286
+ } catch (error) {
287
+ throw new Error(`[@webority/mobile-ui] AuthedImage fetched "${options.uri}" but could not write it to ` + `the app's cache folder: ${error instanceof Error ? error.message : String(error)}. ` + 'The device may be out of storage.');
288
+ }
289
+ const entry = {
205
290
  fetchedAt: now,
206
- bytes: dataUri.length,
291
+ bytes: bytes.byteLength,
207
292
  contentType,
208
- lastAccessedAt: now
293
+ lastAccessedAt: now,
294
+ fileName,
295
+ persistedAccessAt: now
209
296
  };
210
- if (cacheUsable()) {
211
- await _mobileCore.Storage.multiSet({
212
- [`${DATA_PREFIX}${cacheKey}`]: dataUri,
213
- [`${META_PREFIX}${cacheKey}`]: JSON.stringify(meta)
214
- });
215
- await evictIfNeeded();
297
+ const previous = entries.get(cacheKey);
298
+ entries.set(cacheKey, entry);
299
+ if (previous && previous.fileName !== fileName) {
300
+ deleteFile(previous.fileName);
301
+ }
302
+ const evicted = evictIfNeeded(entries);
303
+ await persistIndex(entries);
304
+ if (evicted > 0) {
305
+ _mobileCore.Logger.info(`[AuthedImage] evicted ${evicted} cached image(s) over the byte ceiling`);
216
306
  }
217
307
  return {
218
- dataUri,
219
- meta
308
+ fileUri: file.uri,
309
+ meta: toMeta(entry)
220
310
  };
221
311
  };
222
312
  const inFlight = new Map();
@@ -15,9 +15,8 @@ function _interopRequireWildcard(e, t) { if ("function" == typeof WeakMap) var r
15
15
  /**
16
16
  * Carousel — generic horizontal carousel with pagination, auto-play, and looping.
17
17
  *
18
- * Backed by a virtualized FlatList for memory efficiency. Pagination is driven
19
- * by `Animated.event` with `useNativeDriver` so the indicator tracks the scroll
20
- * gesture frame-perfectly without round-tripping to JS.
18
+ * Backed by a virtualized FlatList for memory efficiency. Dot and bar
19
+ * pagination track the scroll gesture through `Animated.event`.
21
20
  *
22
21
  * USAGE:
23
22
  *
@@ -178,6 +177,9 @@ const CarouselInner = /*#__PURE__*/(0, _react.forwardRef)((props, ref) => {
178
177
  }, [startAutoPlay, clearAutoPlay]);
179
178
 
180
179
  // ─── Scroll handlers ───
180
+ // The dot and bar widths interpolate on the JS driver, so every scroll frame
181
+ // crosses to JS. Only those indicators need it; without them nothing listens.
182
+ const tracksScroll = showPagination && data.length > 1 && paginationStyle !== 'numbers';
181
183
  const onScroll = (0, _react.useMemo)(() => _reactNative.Animated.event([{
182
184
  nativeEvent: {
183
185
  contentOffset: {
@@ -282,8 +284,8 @@ const CarouselInner = /*#__PURE__*/(0, _react.forwardRef)((props, ref) => {
282
284
  snapToAlignment: "start",
283
285
  decelerationRate: "fast",
284
286
  showsHorizontalScrollIndicator: false,
285
- onScroll: onScroll,
286
- scrollEventThrottle: 16,
287
+ onScroll: tracksScroll ? onScroll : undefined,
288
+ scrollEventThrottle: tracksScroll ? 16 : undefined,
287
289
  onMomentumScrollEnd: onMomentumScrollEnd,
288
290
  onScrollBeginDrag: onScrollBeginDrag,
289
291
  onScrollEndDrag: onScrollEndDrag,
@@ -49,19 +49,30 @@ const useFloatingLabel = ({
49
49
  style
50
50
  }) => {
51
51
  const theme = (0, _index.useTheme)();
52
+ // Two values on one timeline. The label only moves by transform, so it runs
53
+ // on the native driver and the float never waits on the JS thread, which is
54
+ // busy exactly then with the keyboard opening. The outlined notch animates a
55
+ // width, which only the JS driver can, so it has its own value.
52
56
  const anim = (0, _react.useRef)((0, _index.createAnimatedValue)(floated ? 1 : 0)).current;
57
+ const notchAnim = (0, _react.useRef)((0, _index.createAnimatedValue)(floated ? 1 : 0)).current;
58
+ const notched = variant === 'outlined';
53
59
  // The label's unscaled layout inside the wrapper; sizes the notch.
54
60
  const [layout, setLayout] = (0, _react.useState)(null);
55
61
  (0, _react.useEffect)(() => {
56
- const a = _reactNative.Animated.timing(anim, {
57
- toValue: floated ? 1 : 0,
62
+ const to = floated ? 1 : 0;
63
+ const timing = (value, useNativeDriver) => _reactNative.Animated.timing(value, {
64
+ toValue: to,
58
65
  duration: theme.motion.duration.fast,
59
66
  easing: _reactNative.Easing.bezier(...theme.motion.easing.standard),
60
- useNativeDriver: false
67
+ useNativeDriver
61
68
  });
69
+ if (!notched) {
70
+ notchAnim.setValue(to);
71
+ }
72
+ const a = notched ? _reactNative.Animated.parallel([timing(anim, true), timing(notchAnim, false)]) : timing(anim, true);
62
73
  a.start();
63
74
  return () => a.stop();
64
- }, [floated, anim, theme.motion.duration.fast, theme.motion.easing.standard]);
75
+ }, [floated, notched, anim, notchAnim, theme.motion.duration.fast, theme.motion.easing.standard]);
65
76
  return (0, _react.useMemo)(() => {
66
77
  if (!enabled || !label) {
67
78
  return {
@@ -117,9 +128,9 @@ const useFloatingLabel = ({
117
128
  // opening with the float animation.
118
129
  const floatedWidth = layout ? layout.width * FLOATED_LABEL_SCALE : 0;
119
130
  const originShift = !layout ? 0 : textAlign === 'right' ? layout.width - floatedWidth : textAlign === 'center' ? (layout.width - floatedWidth) / 2 : 0;
120
- const topNotch = variant === 'outlined' && layout ? {
131
+ const topNotch = notched && layout ? {
121
132
  left: layout.x + floatShift + originShift - NOTCH_GAP,
122
- width: anim.interpolate({
133
+ width: notchAnim.interpolate({
123
134
  inputRange: [0, 1],
124
135
  outputRange: [0, floatedWidth + NOTCH_GAP * 2]
125
136
  })
@@ -158,7 +169,7 @@ const useFloatingLabel = ({
158
169
  node,
159
170
  topNotch
160
171
  };
161
- }, [enabled, label, required, variant, sizeTokens, leadingOffset, trailingOffset, textAlign, paddingLeft, lineHeight, restNudge, floatTrim, color, style, anim, layout, theme]);
172
+ }, [enabled, label, required, notched, sizeTokens, leadingOffset, trailingOffset, textAlign, paddingLeft, lineHeight, restNudge, floatTrim, color, style, anim, notchAnim, layout, theme]);
162
173
  };
163
174
  exports.useFloatingLabel = useFloatingLabel;
164
175
  const styles = _reactNative.StyleSheet.create({
@@ -273,19 +273,35 @@ const OptionSheet = props => {
273
273
  // Rows are not measured until rendered, so a jump far past the rendered
274
274
  // window cannot land in one go: scroll near it by the average row height,
275
275
  // then retry once the window has caught up.
276
+ const jumpRetryRef = (0, _react.useRef)(null);
277
+ const cancelJumpRetry = (0, _react.useCallback)(() => {
278
+ if (jumpRetryRef.current !== null) {
279
+ clearTimeout(jumpRetryRef.current);
280
+ jumpRetryRef.current = null;
281
+ }
282
+ }, []);
276
283
  const handleScrollToIndexFailed = (0, _react.useCallback)(info => {
277
284
  listRef.current?.scrollToOffset({
278
285
  offset: info.averageItemLength * info.index,
279
286
  animated: false
280
287
  });
281
- setTimeout(() => {
288
+ cancelJumpRetry();
289
+ jumpRetryRef.current = setTimeout(() => {
290
+ jumpRetryRef.current = null;
282
291
  listRef.current?.scrollToIndex({
283
292
  index: info.index,
284
293
  animated: true,
285
294
  viewPosition: 0
286
295
  });
287
296
  }, 50);
288
- }, []);
297
+ }, [cancelJumpRetry]);
298
+ // A retry pending when the sheet closes or unmounts would scroll a list that is gone.
299
+ (0, _react.useEffect)(() => {
300
+ if (!rendered) {
301
+ cancelJumpRetry();
302
+ }
303
+ return cancelJumpRetry;
304
+ }, [rendered, cancelJumpRetry]);
289
305
  const renderRow = ({
290
306
  item: row
291
307
  }) => {
@@ -0,0 +1,56 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.valuesEqual = void 0;
7
+ const isPlainObject = value => {
8
+ if (value === null || typeof value !== 'object') {
9
+ return false;
10
+ }
11
+ const proto = Object.getPrototypeOf(value);
12
+ return proto === Object.prototype || proto === null;
13
+ };
14
+
15
+ /**
16
+ * Whether two form value trees hold the same data. The form engine copies only
17
+ * the branch a write touches, so most subtrees are compared by identity and the
18
+ * walk stops early; serialising both trees cost a full pass on every keystroke.
19
+ * A key holding `undefined` counts as absent, the way the serialised comparison
20
+ * treated it, and dates compare by instant.
21
+ */
22
+ const valuesEqual = (a, b) => {
23
+ if (Object.is(a, b)) {
24
+ return true;
25
+ }
26
+ if (a instanceof Date && b instanceof Date) {
27
+ return a.getTime() === b.getTime();
28
+ }
29
+ if (Array.isArray(a) || Array.isArray(b)) {
30
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) {
31
+ return false;
32
+ }
33
+ for (let i = 0; i < a.length; i += 1) {
34
+ if (!valuesEqual(a[i], b[i])) {
35
+ return false;
36
+ }
37
+ }
38
+ return true;
39
+ }
40
+ if (!isPlainObject(a) || !isPlainObject(b)) {
41
+ return false;
42
+ }
43
+ const keysA = Object.keys(a).filter(key => a[key] !== undefined);
44
+ const keysB = Object.keys(b).filter(key => b[key] !== undefined);
45
+ if (keysA.length !== keysB.length) {
46
+ return false;
47
+ }
48
+ for (const key of keysA) {
49
+ if (!valuesEqual(a[key], b[key])) {
50
+ return false;
51
+ }
52
+ }
53
+ return true;
54
+ };
55
+ exports.valuesEqual = valuesEqual;
56
+ //# sourceMappingURL=equal.js.map
@@ -5,6 +5,7 @@ Object.defineProperty(exports, "__esModule", {
5
5
  });
6
6
  exports.useForm = useForm;
7
7
  var _react = require("react");
8
+ var _equal = require("./equal.js");
8
9
  var _path = require("./path.js");
9
10
  // Loosely typed (Values -> any) so the generic schema from any useForm<Values>
10
11
  // instance flows in without variance friction; callers re-apply Values at use.
@@ -55,7 +56,21 @@ function useForm(config) {
55
56
  const fieldOrder = (0, _react.useRef)([]);
56
57
  const focusedFieldRef = (0, _react.useRef)(null);
57
58
 
58
- // biome-ignore lint/correctness/useExhaustiveDependencies: `JSON.stringify(config.initialValues)` is the dependency by design so reinitialize compares by value, not by object identity
59
+ // Reinitialize compares initial values by value, not identity, so a caller
60
+ // passing an inline object literal does not reset the form on every render.
61
+ const initialVersionRef = (0, _react.useRef)({
62
+ values: config.initialValues,
63
+ version: 0
64
+ });
65
+ if (!(0, _equal.valuesEqual)(config.initialValues, initialVersionRef.current.values)) {
66
+ initialVersionRef.current = {
67
+ values: config.initialValues,
68
+ version: initialVersionRef.current.version + 1
69
+ };
70
+ }
71
+ const initialVersion = initialVersionRef.current.version;
72
+
73
+ // biome-ignore lint/correctness/useExhaustiveDependencies: `initialVersion` stands for `config.initialValues` compared by value
59
74
  (0, _react.useEffect)(() => {
60
75
  if (!configRef.current.enableReinitialize) {
61
76
  return;
@@ -68,7 +83,7 @@ function useForm(config) {
68
83
  setValuesState(next);
69
84
  setErrorsState({});
70
85
  setTouchedState({});
71
- }, [JSON.stringify(config.initialValues)]);
86
+ }, [initialVersion]);
72
87
  const validateField = (0, _react.useCallback)(async name => {
73
88
  const validate = configRef.current.validate;
74
89
  const allValues = ref.current.values;
@@ -312,7 +327,7 @@ function useForm(config) {
312
327
  }
313
328
  return true;
314
329
  }, [values, errors]);
315
- const dirty = (0, _react.useMemo)(() => JSON.stringify(values) !== JSON.stringify(initialRef.current), [values]);
330
+ const dirty = (0, _react.useMemo)(() => !(0, _equal.valuesEqual)(values, initialRef.current), [values]);
316
331
  return {
317
332
  values,
318
333
  errors: uiErrors,
@@ -345,7 +360,7 @@ function useForm(config) {
345
360
  error: isTouched || submitCount > 0 ? rawError : undefined,
346
361
  rawError,
347
362
  touched: isTouched,
348
- dirty: JSON.stringify(value) !== JSON.stringify((0, _path.getPath)(initialRef.current, name))
363
+ dirty: !(0, _equal.valuesEqual)(value, (0, _path.getPath)(initialRef.current, name))
349
364
  };
350
365
  },
351
366
  getFieldProps: name => ({