From 78f320b10abae57772e8c91ab1e0de4f50f0c7dd Mon Sep 17 00:00:00 2001 From: Mateus Andrade Date: Wed, 29 Apr 2026 09:43:09 -0300 Subject: [PATCH 1/2] Make Error 'cause' non-enumerable and add tests Define the `cause` property with Object.defineProperty in SensitiveInfoError and HookError so it remains non-enumerable (matching native ES2022 Error semantics) while keeping compatibility with TS libs predating ES2022. Add tests to verify cause chaining, non-enumerability, and omission when not provided. Also update CI workflow triggers to use the 'master' branch for android, ios and test workflows. --- .github/workflows/android-build.yml | 2 +- .github/workflows/ios-build.yml | 2 +- .github/workflows/test.yml | 2 +- src/__tests__/errors.test.ts | 33 +++++++++++++++++++++++++++++ src/__tests__/hooks.types.test.ts | 16 ++++++++++++++ src/errors.ts | 12 ++++++++--- src/hooks/types.ts | 12 ++++++++--- 7 files changed, 70 insertions(+), 9 deletions(-) diff --git a/.github/workflows/android-build.yml b/.github/workflows/android-build.yml index ba7a9fc7..893fbac4 100644 --- a/.github/workflows/android-build.yml +++ b/.github/workflows/android-build.yml @@ -6,7 +6,7 @@ permissions: on: push: branches: - - main + - master paths: - '.github/workflows/android-build.yml' - 'example/android/**' diff --git a/.github/workflows/ios-build.yml b/.github/workflows/ios-build.yml index 2f558207..525ed959 100644 --- a/.github/workflows/ios-build.yml +++ b/.github/workflows/ios-build.yml @@ -6,7 +6,7 @@ permissions: on: push: branches: - - main + - master paths: - '.github/workflows/ios-build.yml' - 'example/ios/**' diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8c681cfd..3625036f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,7 +6,7 @@ permissions: on: push: branches: - - main + - master paths: - '.github/workflows/test.yml' - 'src/**' diff --git a/src/__tests__/errors.test.ts b/src/__tests__/errors.test.ts index 5853beb5..80e015c6 100644 --- a/src/__tests__/errors.test.ts +++ b/src/__tests__/errors.test.ts @@ -61,6 +61,39 @@ 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) + }) + + 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', () => { diff --git a/src/__tests__/hooks.types.test.ts b/src/__tests__/hooks.types.test.ts index 3a9ff546..4584450d 100644 --- a/src/__tests__/hooks.types.test.ts +++ b/src/__tests__/hooks.types.test.ts @@ -19,6 +19,22 @@ 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) + }) + it('creates the initial async state', () => { const state = createInitialAsyncState() expect(state).toEqual({ diff --git a/src/errors.ts b/src/errors.ts index 4e0ff3d8..6d10710e 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -75,11 +75,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, + }) } } } diff --git a/src/hooks/types.ts b/src/hooks/types.ts index e927086f..cb979a68 100644 --- a/src/hooks/types.ts +++ b/src/hooks/types.ts @@ -42,11 +42,17 @@ export class HookError extends Error { this.name = 'HookError' this.operation = operation this.hint = hint - // 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 (cause !== undefined) { - ;(this as { cause?: unknown }).cause = cause + Object.defineProperty(this, 'cause', { + value: cause, + writable: true, + configurable: true, + enumerable: false, + }) } } } From 1f9ded662936bd6b53d84b536ef40ac919ef85ca Mon Sep 17 00:00:00 2001 From: Mateus Andrade Date: Wed, 29 Apr 2026 09:56:05 -0300 Subject: [PATCH 2/2] Support non-enumerable 'cause' on errors Declare a type-only readonly `cause?: unknown` on HookError and SensitiveInfoError so TS configs that predate ES2022 type-check. Update HookError to accept an options object, install the `cause` property via Object.defineProperty when `'cause' in options` (so passing { cause: undefined } still creates a non-enumerable own property), and wire up operation/hint from the options. Add tests for HookError and SensitiveInfoError that assert a non-enumerable own `cause` is defined when explicitly passed as undefined. Also remove react-native-specific exports from package.json. --- package.json | 3 --- src/__tests__/errors.test.ts | 12 ++++++++++++ src/__tests__/hooks.types.test.ts | 10 ++++++++++ src/errors.ts | 10 ++++++++++ src/hooks/types.ts | 27 +++++++++++++++++++-------- 5 files changed, 51 insertions(+), 11 deletions(-) diff --git a/package.json b/package.json index 111c1cb7..f78a9302 100644 --- a/package.json +++ b/package.json @@ -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" @@ -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" @@ -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" diff --git a/src/__tests__/errors.test.ts b/src/__tests__/errors.test.ts index 80e015c6..e7c569a0 100644 --- a/src/__tests__/errors.test.ts +++ b/src/__tests__/errors.test.ts @@ -88,6 +88,18 @@ describe('errors', () => { expect(Object.hasOwn(err, 'cause')).toBe(false) }) + 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 }) diff --git a/src/__tests__/hooks.types.test.ts b/src/__tests__/hooks.types.test.ts index 4584450d..4df9e458 100644 --- a/src/__tests__/hooks.types.test.ts +++ b/src/__tests__/hooks.types.test.ts @@ -35,6 +35,16 @@ describe('hooks/types', () => { expect(Object.hasOwn(error, 'cause')).toBe(false) }) + 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() expect(state).toEqual({ diff --git a/src/errors.ts b/src/errors.ts index 6d10710e..f9423912 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -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. diff --git a/src/hooks/types.ts b/src/hooks/types.ts index cb979a68..bfe76440 100644 --- a/src/hooks/types.ts +++ b/src/hooks/types.ts @@ -30,25 +30,36 @@ 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 + 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), while keeping // the property non-enumerable to match the native ES2022 `Error` constructor. - if (cause !== undefined) { + // + // 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: cause, + value: options.cause, writable: true, configurable: true, enumerable: false,