Skip to content

Commit a9a84f1

Browse files
committed
Tighten FaceID check; improve UTF-8 and docs
Respect explicitly-set Info.plist Face ID values by checking for null/undefined instead of falsiness; update docs to clarify faceIDPermission type/behavior. Fix utf8ByteLength to correctly handle surrogate pairs and unpaired high surrogates (avoid skipping/undercounting). Add unit tests for deepEqual and extend validate tests for UTF-8 edge cases. Document that deepEqual and useStableOptions do not support circular references and that non-serialisable values compare by identity.
1 parent c0c3af9 commit a9a84f1

7 files changed

Lines changed: 114 additions & 15 deletions

File tree

app.plugin.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,8 @@ function withIosNewArchitecture(config) {
5757
function withFaceIDUsageDescription(config, faceIDPermission) {
5858
if (faceIDPermission === null) return config
5959
return withInfoPlist(config, (modConfig) => {
60-
// Respect a user-set value; only fill the default when missing.
61-
if (!modConfig.modResults.NSFaceIDUsageDescription) {
60+
// Respect any user-set value (including empty strings); only fill when truly missing.
61+
if (modConfig.modResults.NSFaceIDUsageDescription == null) {
6262
modConfig.modResults.NSFaceIDUsageDescription =
6363
faceIDPermission ?? DEFAULT_FACE_ID_PERMISSION
6464
}

docs/EXPO.md

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,10 @@ Add the plugin to your `app.json` / `app.config.ts`:
3939

4040
### Plugin options
4141

42-
| Prop | Type | Default | Effect |
43-
| ----------------------- | --------- | --------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
44-
| `faceIDPermission` | `string` | `"Authenticate to access your secure data."` | Written to `NSFaceIDUsageDescription` if missing. A pre-existing value in your Info.plist is preserved. |
45-
| `faceIDPermission` | `null` || Skips the Info.plist modifier entirely (use when another plugin owns the key). |
46-
| `enableNewArchitecture` | `boolean` | `true` | Writes `newArchEnabled` (Android) and `RCT_NEW_ARCH_ENABLED` (iOS) flags. |
42+
| Prop | Type | Default | Effect |
43+
| ----------------------- | ---------------- | -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
44+
| `faceIDPermission` | `string \| null` | `"Authenticate to access your secure data."` | When a string is provided, it is written to `NSFaceIDUsageDescription` if the key is missing; a pre-existing value in your Info.plist is always preserved. Pass `null` to skip the modifier entirely (e.g. another plugin owns the key). |
45+
| `enableNewArchitecture` | `boolean` | `true` | Writes `newArchEnabled` (Android) and `RCT_NEW_ARCH_ENABLED` (iOS) flags. |
4746

4847
The plugin also adds the following Android permissions automatically:
4948

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import deepEqual from '../hooks/internal/deepEqual'
2+
3+
describe('hooks/internal/deepEqual', () => {
4+
it('returns true for identical primitives (Object.is semantics)', () => {
5+
expect(deepEqual(1, 1)).toBe(true)
6+
expect(deepEqual('a', 'a')).toBe(true)
7+
expect(deepEqual(Number.NaN, Number.NaN)).toBe(true)
8+
})
9+
10+
it('returns false for differing primitives', () => {
11+
expect(deepEqual(1, 2)).toBe(false)
12+
expect(deepEqual('a', 'b')).toBe(false)
13+
expect(deepEqual(0, -0)).toBe(false)
14+
})
15+
16+
it('returns false when comparing null with an object', () => {
17+
expect(deepEqual(null, {})).toBe(false)
18+
expect(deepEqual({}, null)).toBe(false)
19+
})
20+
21+
it('returns false when comparing primitive with object', () => {
22+
expect(deepEqual(1, {})).toBe(false)
23+
})
24+
25+
it('compares plain objects structurally', () => {
26+
expect(deepEqual({ a: 1, b: 2 }, { b: 2, a: 1 })).toBe(true)
27+
expect(deepEqual({ a: 1 }, { a: 1, b: undefined })).toBe(false)
28+
expect(deepEqual({ a: 1, b: 2 }, { a: 1 })).toBe(false)
29+
})
30+
31+
it('compares arrays structurally', () => {
32+
expect(deepEqual([1, 2, 3], [1, 2, 3])).toBe(true)
33+
expect(deepEqual([1, 2], [1, 2, 3])).toBe(false)
34+
expect(deepEqual([1, 2, 3], [1, 2, 4])).toBe(false)
35+
})
36+
37+
it('returns false when only one side is an array', () => {
38+
expect(deepEqual([1], { 0: 1, length: 1 })).toBe(false)
39+
})
40+
41+
it('recurses through nested objects and arrays', () => {
42+
expect(deepEqual({ a: [1, { b: 2 }] }, { a: [1, { b: 2 }] })).toBe(true)
43+
expect(deepEqual({ a: [1, { b: 2 }] }, { a: [1, { b: 3 }] })).toBe(false)
44+
})
45+
46+
it('returns false for non-plain object instances', () => {
47+
expect(deepEqual(new Date(0), new Date(0))).toBe(false)
48+
expect(deepEqual(/a/, /a/)).toBe(false)
49+
})
50+
51+
it('treats Object.create(null) as a plain object', () => {
52+
const a = Object.create(null) as Record<string, unknown>
53+
a.x = 1
54+
const b = Object.create(null) as Record<string, unknown>
55+
b.x = 1
56+
expect(deepEqual(a, b)).toBe(true)
57+
})
58+
})

src/__tests__/internal.validate.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,5 +82,30 @@ describe('internal/validate', () => {
8282
// '\u{1F600}' (😀) is 4 UTF-8 bytes; allow well-under-the-limit input through.
8383
expect(() => validateValue('\u{1F600}')).not.toThrow()
8484
})
85+
86+
it('counts 2-byte UTF-8 sequences (e.g. accented Latin)', () => {
87+
// 'é' (U+00E9) is 2 UTF-8 bytes; ensure the 2-byte branch is exercised.
88+
const atLimit = `${'x'.repeat(MAX_VALUE_BYTES - 2)}é`
89+
const tooBig = `${'x'.repeat(MAX_VALUE_BYTES - 1)}é`
90+
expect(() => validateValue(atLimit)).not.toThrow()
91+
expect(() => validateValue(tooBig)).toThrow(InvalidArgumentError)
92+
})
93+
94+
it('treats a lone high surrogate as a 3-byte sequence (no skipping)', () => {
95+
// Unpaired high surrogate at end of string should count as 3 bytes (replacement),
96+
// not 4, and must not advance past end of string.
97+
const atLimit = `${'x'.repeat(MAX_VALUE_BYTES - 3)}\uD83D`
98+
const tooBig = `${'x'.repeat(MAX_VALUE_BYTES - 2)}\uD83D`
99+
expect(() => validateValue(atLimit)).not.toThrow()
100+
expect(() => validateValue(tooBig)).toThrow(InvalidArgumentError)
101+
})
102+
103+
it('handles a high surrogate followed by a non-low surrogate without skipping the next character', () => {
104+
// '\uD83D' is unpaired (3 bytes) and 'a' is 1 byte — total tail is 4 bytes.
105+
const atLimit = `${'x'.repeat(MAX_VALUE_BYTES - 4)}\uD83Da`
106+
const tooBig = `${'x'.repeat(MAX_VALUE_BYTES - 3)}\uD83Da`
107+
expect(() => validateValue(atLimit)).not.toThrow()
108+
expect(() => validateValue(tooBig)).toThrow(InvalidArgumentError)
109+
})
85110
})
86111
})

src/hooks/internal/deepEqual.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
* - Returns `false` (rather than recursing) for instances of `Date`, `RegExp`, `Map`, `Set`,
1111
* functions, and any other non-plain object: option payloads are POJOs by contract, and these
1212
* types should never appear there. Guarding against them prevents accidental false positives.
13+
* - **Circular references are not supported** and will recurse until the call stack overflows.
14+
* Option payloads must be acyclic; this is enforced by contract, not at runtime.
1315
*
1416
* @internal
1517
*/

src/hooks/useStableOptions.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,11 @@ import deepEqual from './internal/deepEqual'
1010
* so it correctly handles:
1111
* - Key order: `{ a: 1, b: 2 }` and `{ b: 2, a: 1 }` are treated as equal.
1212
* - `undefined` values: `{ a: 1, b: undefined }` and `{ a: 1 }` are treated as **different**.
13-
* - Circular references and non-serialisable values do not throw.
13+
* - Non-serialisable values (`Date`, `RegExp`, `Map`, `Set`, functions) compare by identity
14+
* rather than throwing.
15+
*
16+
* Option payloads must be **acyclic** — circular references are not supported and will
17+
* recurse until the call stack overflows. This is by contract; option objects are POJOs.
1418
*
1519
* The merged result is cached until either input changes by content, so consumers can pass
1620
* inline option literals without forcing downstream `useEffect` / `useMemo` invalidations.

src/internal/validate.ts

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,24 @@ function utf8ByteLength(input: string): number {
2222
let bytes = 0
2323
for (let i = 0; i < input.length; i++) {
2424
const code = input.charCodeAt(i)
25-
if (code < 0x80) bytes += 1
26-
else if (code < 0x800) bytes += 2
27-
else if (code >= 0xd800 && code <= 0xdbff) {
28-
// High surrogate — combined with the next low surrogate is a 4-byte UTF-8 sequence.
29-
bytes += 4
30-
i++
31-
} else bytes += 3
25+
if (code < 0x80) {
26+
bytes += 1
27+
} else if (code < 0x800) {
28+
bytes += 2
29+
} else if (code >= 0xd800 && code <= 0xdbff) {
30+
// High surrogate. Only count as a 4-byte sequence when properly paired with a
31+
// following low surrogate; otherwise treat the unpaired surrogate as a 3-byte
32+
// replacement to avoid skipping or undercounting the next code unit.
33+
const next = i + 1 < input.length ? input.charCodeAt(i + 1) : 0
34+
if (next >= 0xdc00 && next <= 0xdfff) {
35+
bytes += 4
36+
i++
37+
} else {
38+
bytes += 3
39+
}
40+
} else {
41+
bytes += 3
42+
}
3243
}
3344
return bytes
3445
}

0 commit comments

Comments
 (0)