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