@webority/mobile-ui 0.0.36 → 0.0.38

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.
@@ -32,22 +32,6 @@ const getAuthedImageCacheLimit = () => byteCeiling;
32
32
  */
33
33
  exports.getAuthedImageCacheLimit = getAuthedImageCacheLimit;
34
34
  const deriveCacheKey = (uri, override) => (0, _hashString.hashString)(override ?? uri);
35
- 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];
48
- }
49
- return out;
50
- };
51
35
 
52
36
  /**
53
37
  * The cache is an OPTIMISATION, so a failing store must never stop an image
@@ -60,6 +44,7 @@ const encodeBase64 = buffer => {
60
44
  * handling below already covers: each read returns null and each write returns
61
45
  * false, the image still fetches, and nothing is logged per image.
62
46
  */
47
+ exports.deriveCacheKey = deriveCacheKey;
63
48
  const cacheUsable = () => (0, _mobileCore.isStorageAvailable)();
64
49
  const readEntry = async cacheKey => {
65
50
  if (!cacheUsable()) {
@@ -197,7 +182,7 @@ const fetchAndStore = async (cacheKey, options, signal) => {
197
182
  const buffer = response.data ?? new ArrayBuffer(0);
198
183
  const rawType = responseHeader(response.headers, 'content-type');
199
184
  const contentType = (rawType ? rawType.split(';')[0].trim() : '') || 'image/jpeg';
200
- const dataUri = `data:${contentType};base64,${encodeBase64(buffer)}`;
185
+ const dataUri = `data:${contentType};base64,${(0, _mobileCore.encodeBase64)(buffer)}`;
201
186
  const now = Date.now();
202
187
  // `bytes` measures the stored data URI, not the decoded image: the ceiling
203
188
  // exists to bound what sits in storage, and base64 costs about a third more.
@@ -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({
@@ -305,7 +305,7 @@ const Modal = exports.Modal = /*#__PURE__*/(0, _react.forwardRef)((props, ref) =
305
305
  }
306
306
  },
307
307
  style: styles.root,
308
- accessible: true,
308
+ accessible: false,
309
309
  accessibilityViewIsModal: true,
310
310
  accessibilityRole: "alert",
311
311
  accessibilityLabel: accessibilityLabel,
@@ -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 => ({
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
 
3
- import { ACCESS_TOKEN_KEY, getToken, isStorageAvailable, Logger, publicClient, Storage } from '@webority/mobile-core';
3
+ import { ACCESS_TOKEN_KEY, encodeBase64, getToken, isStorageAvailable, Logger, publicClient, Storage } from '@webority/mobile-core';
4
4
  import { hashString } from "../../utils/hashString.js";
5
5
  const KEY_PREFIX = '@webority/authed-image/v1/';
6
6
  const DATA_PREFIX = `${KEY_PREFIX}d/`;
@@ -26,21 +26,6 @@ export const getAuthedImageCacheLimit = () => byteCeiling;
26
26
  * Hex only by construction, so it is always a safe storage key segment.
27
27
  */
28
28
  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;
43
- };
44
29
 
45
30
  /**
46
31
  * The cache is an OPTIMISATION, so a failing store must never stop an image
@@ -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: {
@@ -300,7 +300,7 @@ const Modal = /*#__PURE__*/forwardRef((props, ref) => {
300
300
  }
301
301
  },
302
302
  style: styles.root,
303
- accessible: true,
303
+ accessible: false,
304
304
  accessibilityViewIsModal: true,
305
305
  accessibilityRole: "alert",
306
306
  accessibilityLabel: accessibilityLabel,
@@ -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 => ({
@@ -1,9 +1,8 @@
1
1
  /**
2
2
  * Carousel — generic horizontal carousel with pagination, auto-play, and looping.
3
3
  *
4
- * Backed by a virtualized FlatList for memory efficiency. Pagination is driven
5
- * by `Animated.event` with `useNativeDriver` so the indicator tracks the scroll
6
- * gesture frame-perfectly without round-tripping to JS.
4
+ * Backed by a virtualized FlatList for memory efficiency. Dot and bar
5
+ * pagination track the scroll gesture through `Animated.event`.
7
6
  *
8
7
  * USAGE:
9
8
  *
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Whether two form value trees hold the same data. The form engine copies only
3
+ * the branch a write touches, so most subtrees are compared by identity and the
4
+ * walk stops early; serialising both trees cost a full pass on every keystroke.
5
+ * A key holding `undefined` counts as absent, the way the serialised comparison
6
+ * treated it, and dates compare by instant.
7
+ */
8
+ export declare const valuesEqual: (a: unknown, b: unknown) => boolean;
9
+ //# sourceMappingURL=equal.d.ts.map
@@ -1,9 +1,8 @@
1
1
  /**
2
2
  * Carousel — generic horizontal carousel with pagination, auto-play, and looping.
3
3
  *
4
- * Backed by a virtualized FlatList for memory efficiency. Pagination is driven
5
- * by `Animated.event` with `useNativeDriver` so the indicator tracks the scroll
6
- * gesture frame-perfectly without round-tripping to JS.
4
+ * Backed by a virtualized FlatList for memory efficiency. Dot and bar
5
+ * pagination track the scroll gesture through `Animated.event`.
7
6
  *
8
7
  * USAGE:
9
8
  *
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Whether two form value trees hold the same data. The form engine copies only
3
+ * the branch a write touches, so most subtrees are compared by identity and the
4
+ * walk stops early; serialising both trees cost a full pass on every keystroke.
5
+ * A key holding `undefined` counts as absent, the way the serialised comparison
6
+ * treated it, and dates compare by instant.
7
+ */
8
+ export declare const valuesEqual: (a: unknown, b: unknown) => boolean;
9
+ //# sourceMappingURL=equal.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webority/mobile-ui",
3
- "version": "0.0.36",
3
+ "version": "0.0.38",
4
4
  "description": "Beautiful, animated, accessible React Native components, theme and form engine for Webority projects.",
5
5
  "keywords": [
6
6
  "react-native",
@@ -64,7 +64,7 @@
64
64
  "typecheck": "tsc --noEmit"
65
65
  },
66
66
  "peerDependencies": {
67
- "@webority/mobile-core": ">=0.0.26 <0.1.0",
67
+ "@webority/mobile-core": ">=0.0.27 <0.1.0",
68
68
  "expo": ">=57.0.0",
69
69
  "expo-haptics": ">=57.0.0",
70
70
  "react": "^19.1.0",