Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/android-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ permissions:
on:
push:
branches:
- main
- master
paths:
- '.github/workflows/android-build.yml'
- 'example/android/**'
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/ios-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ permissions:
on:
push:
branches:
- main
- master
paths:
- '.github/workflows/ios-build.yml'
- 'example/ios/**'
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ permissions:
on:
push:
branches:
- main
- master
paths:
- '.github/workflows/test.yml'
- 'src/**'
Expand Down
3 changes: 0 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
"sideEffects": false,
"exports": {
".": {
"react-native": "./src/index.ts",
"import": {
"types": "./lib/typescript/module/src/index.d.ts",
"default": "./lib/module/index.js"
Expand All @@ -22,7 +21,6 @@
"default": "./lib/module/index.js"
},
"./hooks": {
"react-native": "./src/hooks/index.ts",
"import": {
"types": "./lib/typescript/module/src/hooks/index.d.ts",
"default": "./lib/module/hooks/index.js"
Expand All @@ -34,7 +32,6 @@
"default": "./lib/module/hooks/index.js"
},
"./errors": {
"react-native": "./src/errors.ts",
"import": {
"types": "./lib/typescript/module/src/errors.d.ts",
"default": "./lib/module/errors.js"
Expand Down
45 changes: 45 additions & 0 deletions src/__tests__/errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,51 @@ describe('errors', () => {
const err = new KeyInvalidatedError('invalid', { alias: 'rnsi.svc.v1' })
expect(err.alias).toBe('rnsi.svc.v1')
})

describe('cause chaining', () => {
it('retains the provided cause on SensitiveInfoError', () => {
const cause = new Error('underlying')
const err = new SensitiveInfoError(ErrorCode.NotFound, 'wrapped', {
cause,
})
expect(err.cause).toBe(cause)
})

it('keeps cause non-enumerable to match native ES2022 Error semantics', () => {
const cause = new Error('underlying')
const err = new SensitiveInfoError(ErrorCode.NotFound, 'wrapped', {
cause,
})
const descriptor = Object.getOwnPropertyDescriptor(err, 'cause')
expect(descriptor).toBeDefined()
expect(descriptor?.enumerable).toBe(false)
expect(Object.keys(err)).not.toContain('cause')
expect(JSON.parse(JSON.stringify(err))).not.toHaveProperty('cause')
})

it('does not define cause when not provided', () => {
const err = new SensitiveInfoError(ErrorCode.NotFound, 'wrapped')
expect(Object.hasOwn(err, 'cause')).toBe(false)
})

Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider adding a test for the ES2022 edge case where options explicitly include { cause: undefined }. Native Error still defines an own (non-enumerable) cause property in that case, and covering it here would make the intended semantics for SensitiveInfoError explicit.

Suggested change
it('defines a non-enumerable own cause when explicitly passed as undefined', () => {
const err = new SensitiveInfoError(ErrorCode.NotFound, 'wrapped', {
cause: undefined,
})
const descriptor = Object.getOwnPropertyDescriptor(err, 'cause')
expect(err.cause).toBeUndefined()
expect(Object.hasOwn(err, 'cause')).toBe(true)
expect(descriptor).toBeDefined()
expect(descriptor?.enumerable).toBe(false)
expect(Object.keys(err)).not.toContain('cause')
})

Copilot uses AI. Check for mistakes.
it('defines a non-enumerable own cause when explicitly passed as undefined', () => {
const err = new SensitiveInfoError(ErrorCode.NotFound, 'wrapped', {
cause: undefined,
})
const descriptor = Object.getOwnPropertyDescriptor(err, 'cause')
expect(Object.hasOwn(err, 'cause')).toBe(true)
expect(err.cause).toBeUndefined()
expect(descriptor).toBeDefined()
expect(descriptor?.enumerable).toBe(false)
expect(Object.keys(err)).not.toContain('cause')
})

it('propagates cause through subclasses (NotFoundError)', () => {
const cause = new Error('native miss')
const err = new NotFoundError('missing', { cause })
expect(err.cause).toBe(cause)
})
})
})

describe('toSensitiveInfoError', () => {
Expand Down
26 changes: 26 additions & 0 deletions src/__tests__/hooks.types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,32 @@ describe('hooks/types', () => {
expect(error.hint).toBe('Check the key.')
})

it('keeps cause non-enumerable to match native ES2022 Error semantics', () => {
const cause = new Error('native failure')
const error = new HookError('Wrapper message', { cause })

const descriptor = Object.getOwnPropertyDescriptor(error, 'cause')
expect(descriptor).toBeDefined()
expect(descriptor?.enumerable).toBe(false)
expect(Object.keys(error)).not.toContain('cause')
expect(JSON.parse(JSON.stringify(error))).not.toHaveProperty('cause')
})

it('omits cause when not provided', () => {
const error = new HookError('Wrapper message')
expect(Object.hasOwn(error, 'cause')).toBe(false)
})
Comment on lines +22 to +36
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tests assert non-enumerability and omission when no options are passed, but they don’t cover the ES2022 edge case of explicitly providing { cause: undefined }. Native Error still defines an own non-enumerable cause property in that case; adding a test for it would prevent regressions and clarify intended semantics for HookError.

Copilot uses AI. Check for mistakes.

it('defines a non-enumerable own cause when explicitly passed as undefined', () => {
const error = new HookError('Wrapper message', { cause: undefined })
const descriptor = Object.getOwnPropertyDescriptor(error, 'cause')
expect(Object.hasOwn(error, 'cause')).toBe(true)
expect(error.cause).toBeUndefined()
expect(descriptor).toBeDefined()
expect(descriptor?.enumerable).toBe(false)
expect(Object.keys(error)).not.toContain('cause')
})

it('creates the initial async state', () => {
const state = createInitialAsyncState<string>()
expect(state).toEqual({
Expand Down
22 changes: 19 additions & 3 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,16 @@ export class SensitiveInfoError extends Error {
/** Stable discriminant identifying the failure mode. */
readonly code: ErrorCodeValue

/**
* The underlying cause forwarded to {@link Error.cause}.
*
* Declared as a type-only member so the property type-checks under `tsconfig`
* `lib` targets that predate ES2022 (where {@link Error} did not yet expose
* `cause`). At runtime the value is installed by the constructor via
* {@link Object.defineProperty} so it stays non-enumerable.
*/
declare readonly cause?: unknown

/**
* @param code - Stable {@link ErrorCodeValue} for the failure.
* @param message - Human-readable description.
Expand All @@ -75,11 +85,17 @@ export class SensitiveInfoError extends Error {
super(message)
this.name = 'SensitiveInfoError'
this.code = code
// Assign `cause` directly instead of passing it to `super()` so this
// Define `cause` manually instead of passing it to `super()` so this
// compiles cleanly under TS configs whose `lib` predates ES2022 (where
// the second `Error` constructor argument was introduced).
// the second `Error` constructor argument was introduced), while keeping
// the property non-enumerable to match the native ES2022 `Error` constructor.
if (options && 'cause' in options) {
;(this as { cause?: unknown }).cause = options.cause
Object.defineProperty(this, 'cause', {
value: options.cause,
writable: true,
configurable: true,
enumerable: false,
})
}
Comment on lines 85 to 99
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If “TS libs predating ES2022” compatibility is a goal, consumers compiling with older lib targets won’t see cause on Error, and SensitiveInfoError doesn’t declare it either. Consider adding a type-only member declare readonly cause?: unknown to SensitiveInfoError so err.cause type-checks without emitting a real class field (which would otherwise create an enumerable own property and defeat the non-enumerability change).

Copilot uses AI. Check for mistakes.
}
}
Expand Down
37 changes: 27 additions & 10 deletions src/hooks/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,23 +30,40 @@ export class HookError extends Error {
/** UI-facing remediation hint (e.g. `'Ask the user to retry biometrics.'`). */
readonly hint?: string | undefined

/**
* The underlying cause forwarded to {@link Error.cause}.
*
* Declared as a type-only member so the property type-checks under `tsconfig`
* `lib` targets that predate ES2022 (where {@link Error} did not yet expose
* `cause`). At runtime the value is installed by the constructor via
* {@link Object.defineProperty} so it stays non-enumerable.
*/
declare readonly cause?: unknown

/**
* @param message - Human-readable description of the failure.
* @param options - Additional metadata; see {@link HookErrorOptions}.
*/
constructor(
message: string,
{ cause, operation, hint }: HookErrorOptions = {}
) {
constructor(message: string, options: HookErrorOptions = {}) {
super(message)
this.name = 'HookError'
this.operation = operation
this.hint = hint
// Assign `cause` directly instead of passing it to `super()` so this
this.operation = options.operation
this.hint = options.hint
// Define `cause` manually instead of passing it to `super()` so this
// compiles cleanly under TS configs whose `lib` predates ES2022 (where
// the second `Error` constructor argument was introduced).
if (cause !== undefined) {
;(this as { cause?: unknown }).cause = cause
// the second `Error` constructor argument was introduced), while keeping
// the property non-enumerable to match the native ES2022 `Error` constructor.
Comment on lines 49 to +55
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the goal is compatibility with TS libs predating ES2022, consumers on older lib targets won’t have Error['cause'] in their type definitions, and HookError doesn’t declare it either. To preserve typing without changing runtime enumerability, add a type-only class member like declare readonly cause?: unknown (avoid a real class field, which would emit JS and create an enumerable own property).

Copilot uses AI. Check for mistakes.
//
// We deliberately use `'cause' in options` (rather than a value check)
// so that `new HookError(msg, { cause: undefined })` still installs the
// property — matching native `new Error(msg, { cause: undefined })`.
if ('cause' in options) {
Object.defineProperty(this, 'cause', {
value: options.cause,
writable: true,
configurable: true,
enumerable: false,
})
}
}
}
Expand Down
Loading