diff --git a/.github/workflows/beta-release.yml b/.github/workflows/beta-release.yml new file mode 100644 index 0000000..57e9454 --- /dev/null +++ b/.github/workflows/beta-release.yml @@ -0,0 +1,100 @@ +name: Beta Release +on: + push: + branches: + - beta + pull_request: + branches: + - beta + merge_group: + types: + - checks_requested + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup + uses: ./.github/actions/setup + + - name: Lint files + run: yarn lint + + - name: Typecheck files + run: yarn typecheck + + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup + uses: ./.github/actions/setup + + - name: Run unit tests + run: yarn test --maxWorkers=2 --coverage + + build-library: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup + uses: ./.github/actions/setup + + - name: Build package + run: yarn prepack + + build-web: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup + uses: ./.github/actions/setup + + - name: Build example for Web + run: | + yarn example expo export:web + + publish-beta: + needs: [lint, test, build-library, build-web] + runs-on: ubuntu-latest + permissions: + contents: write # To publish a GitHub release + issues: write # To comment on released issues + pull-requests: write # To comment on released pull requests + id-token: write # To enable use of OIDC for npm provenance + if: github.ref == 'refs/heads/beta' + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Ensures all tags are fetched + + - name: Setup + uses: ./.github/actions/setup + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "lts/*" # Use the latest LTS version of Node.js + registry-url: 'https://registry.npmjs.org/' # Specify npm registry + + - name: Verify the integrity of provenance attestations and registry signatures for installed dependencies + run: npm audit signatures # Check the signatures to verify integrity + + - name: Release Beta + run: npx semantic-release # Run semantic-release to manage versioning and publishing + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # GitHub token for authentication + + # Why NODE_AUTH_TOKEN instead of NPM_TOKEN: https://github.com/semantic-release/semantic-release/issues/2313 + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} # npm token for publishing package + \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 9387796..1be8721 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,37 @@ +# [1.4.0-beta.3](https://github.com/JairajJangle/react-native-visibility-sensor/compare/v1.4.0-beta.2...v1.4.0-beta.3) (2025-08-26) + + +### Bug Fixes + +* **perf:** reduced unnecessary onPercentChange callback calls when last percent is already 0 ([39e8aa8](https://github.com/JairajJangle/react-native-visibility-sensor/commit/39e8aa847b42eabe9341e4bec96c35f1b8c67972)) + +# [1.4.0-beta.2](https://github.com/JairajJangle/react-native-visibility-sensor/compare/v1.4.0-beta.1...v1.4.0-beta.2) (2025-08-23) + + +### Bug Fixes + +* added missing state dependencies in visiblity calculations ([af0161c](https://github.com/JairajJangle/react-native-visibility-sensor/commit/af0161c6fcfb4d72f7db97e7fb1c88cc96706c13)) + +# [1.4.0-beta.1](https://github.com/JairajJangle/react-native-visibility-sensor/compare/v1.3.22...v1.4.0-beta.1) (2025-08-23) + + +### Bug Fixes + +* add window dimension change listener to handle orientation changes ([5f640db](https://github.com/JairajJangle/react-native-visibility-sensor/commit/5f640db1be9f447f9b82aece4b1e9a366df36573)) +* handle initial visibility state and improve measurement timing ([f5e4de7](https://github.com/JairajJangle/react-native-visibility-sensor/commit/f5e4de7dd7802951a5654fe9ab8cf574f9f4d52d)) +* prevent race conditions during rapid mount/unmount cycles ([de6cb59](https://github.com/JairajJangle/react-native-visibility-sensor/commit/de6cb59111b4b8944d3070bb826290b8941340c5)) +* prevent state updates on unmounted components ([6c9786a](https://github.com/JairajJangle/react-native-visibility-sensor/commit/6c9786a1ccc46e5c48813e349a7e6aabb9b4052a)) + + +### Features + +* added percent visiblity callback requested in [#44](https://github.com/JairajJangle/react-native-visibility-sensor/issues/44) ([e78fa27](https://github.com/JairajJangle/react-native-visibility-sensor/commit/e78fa27a9fe2d8c37227f3ab6022964aeafe8b37)) + + +### Performance Improvements + +* conserved percent calc. if view is not visible - [#44](https://github.com/JairajJangle/react-native-visibility-sensor/issues/44) ([81d0036](https://github.com/JairajJangle/react-native-visibility-sensor/commit/81d003651348dcfb871d832137330d174f1f56e2)) + ## [1.3.22](https://github.com/JairajJangle/react-native-visibility-sensor/compare/v1.3.21...v1.3.22) (2025-08-12) diff --git a/README.md b/README.md index 2334b5f..e7e6def 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,11 @@ [![npm version](https://img.shields.io/npm/v/%40futurejj%2Freact-native-visibility-sensor)](https://badge.fury.io/js/%40futurejj%2Freact-native-visibility-sensor) [![License](https://img.shields.io/github/license/JairajJangle/react-native-visibility-sensor)](https://github.com/JairajJangle/react-native-visibility-sensor/blob/main/LICENSE) [![Workflow Status](https://github.com/JairajJangle/react-native-visibility-sensor/actions/workflows/ci.yml/badge.svg)](https://github.com/JairajJangle/react-native-visibility-sensor/actions/workflows/ci.yml) ![Android](https://img.shields.io/badge/-Android-555555?logo=android&logoColor=3DDC84) ![iOS](https://img.shields.io/badge/-iOS-555555?logo=apple&logoColor=white) ![Web](https://img.shields.io/badge/-Web-555555?logo=google-chrome&logoColor=0096FF) [![GitHub issues](https://img.shields.io/github/issues/JairajJangle/react-native-visibility-sensor)](https://github.com/JairajJangle/react-native-visibility-sensor/issues?q=is%3Aopen+is%3Aissue) ![TS](https://img.shields.io/badge/TypeScript-strict_💪-blue) [![Expo Snack](https://img.shields.io/badge/Expo%20Snack-555555?style=flat&logo=expo&logoColor=white)](https://snack.expo.dev/@futurejj/react-native-visibility-sensor-example) ![NPM Downloads](https://img.shields.io/npm/dm/%40futurejj%2Freact-native-visibility-sensor) ![npm bundle size](https://img.shields.io/bundlephobia/minzip/%40futurejj%2Freact-native-visibility-sensor)
- Visibility Sensor demo + Visibility Sensor demo
+ ## Installation Using yarn @@ -26,32 +27,28 @@ npm install @futurejj/react-native-visibility-sensor ## Usage -```typescript -import React from 'react'; +```tsx +import React, { useState } from 'react'; import { ScrollView, Text } from 'react-native'; import { VisibilitySensor } from '@futurejj/react-native-visibility-sensor'; export default function VisibilitySensorExample() { - const [isInView, setIsInView] = React.useState(false); - - function checkVisible(isVisible: boolean) { - if (isVisible) { - setIsInView(isVisible); - } else { - setIsInView(isVisible); - } - } + const [isVisible, setIsVisible] = useState(false); + const [percentVisible, setPercentVisible] = useState(0); return ( checkVisible(isVisible)} - threshold={{ - left: 100, - right: 100, - }} - style={[styles.item, { backgroundColor: isInView ? 'green' : 'red' }]}> - This View is currently visible? {isInView ? 'yes' : 'no'} + onChange={setIsVisible} + onPercentChange={setPercentVisible} // optional callback for % change + threshold={{ top: 100, bottom: 100 }} + style={[styles.item, { backgroundColor: isVisible ? 'green' : 'red' }]}> + + {/* Visibility state */} + This View is currently visible? {isVisible ? 'yes' : 'no'} + + {/* Percent visibility state */} + {`Percent visible: ${percentVisible}%`} ); @@ -61,13 +58,14 @@ export default function VisibilitySensorExample() { `VisibilitySensorProps` extends `ViewProps` from React Native, which includes common properties for all views, such as `style`, `onLayout`, etc. -| Property | Type | Required | Description | -| ----------- | ------------------------------------------------------- | -------- | ------------------------------------------------------------ | -| onChange | (visible: boolean) => void | Yes | Callback function that fires when visibility changes. | -| disabled | boolean | No | If `true`, disables the sensor. | -| triggerOnce | boolean | No | If `true`, the sensor will only trigger once. | -| delay | number \| undefined | No | The delay in milliseconds before the sensor triggers. | -| threshold | [VisibilitySensorThreshold](#visibilitysensorthreshold) | No | Defines the part of the view that must be visible for the sensor to trigger. | +| Property | Type | Required | Description | +| ----------------- | ------------------------------------------------------- | -------- | ------------------------------------------------------------ | +| `onChange` | `(visible: boolean) => void` | Yes | Callback function that fires when visibility changes. | +| `onPercentChange` | `(percentVisible: number) => void` | No | Callback function that fires when visibility % changes. | +| `disabled` | `boolean` | No | If `true`, disables the sensor. | +| `triggerOnce` | `boolean` | No | If `true`, the sensor will only trigger once. | +| `delay` | `number` or `undefined` | No | The delay in milliseconds before the sensor triggers. | +| `threshold` | [VisibilitySensorThreshold](#visibilitysensorthreshold) | No | Defines the part of the view that must be visible for the sensor to trigger. | Additionally, all properties from `ViewProps` are also applicable. @@ -75,12 +73,12 @@ Additionally, all properties from `ViewProps` are also applicable. ### VisibilitySensorThreshold -| Property | Type | Required | Description | -| -------- | ------ | -------- | ------------------------------------------ | -| top | number | No | The top threshold value for visibility. | -| bottom | number | No | The bottom threshold value for visibility. | -| left | number | No | The left threshold value for visibility. | -| right | number | No | The right threshold value for visibility. | +| Property | Type | Required | Description | +| -------- | -------- | -------- | ------------------------------------------ | +| `top` | `number` | No | The top threshold value for visibility. | +| `bottom` | `number` | No | The bottom threshold value for visibility. | +| `left` | `number` | No | The left threshold value for visibility. | +| `right` | `number` | No | The right threshold value for visibility. | --- diff --git a/example/package.json b/example/package.json index f5b1da2..5ea462a 100644 --- a/example/package.json +++ b/example/package.json @@ -9,10 +9,10 @@ "web": "expo start --web" }, "dependencies": { - "expo": "^53.0.20", + "expo": "^53.0.22", "react": "19.0.0", "react-dom": "19.0.0", - "react-native": "0.79.3", + "react-native": "0.79.5", "react-native-web": "^0.20.0" }, "devDependencies": { diff --git a/example/src/App.tsx b/example/src/App.tsx index be5e0d8..341ed37 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -1,8 +1,7 @@ -import * as React from 'react'; +import React, { useEffect, useState } from 'react'; import { FlatList, Image, - SafeAreaView, StyleSheet, Text, View, @@ -10,17 +9,19 @@ import { } from 'react-native'; import { VisibilitySensor } from '@futurejj/react-native-visibility-sensor'; import { pocketMonsters, type PocketMonsterInfo } from './pocketMonsters'; -import { useCallback, useState } from 'react'; + +const topThreshold = 200; +const bottomThreshold = 200; export default function App() { const renderItems: ListRenderItem = ({ item }) => ( - + ); return ( - + <> index.toString()} @@ -29,71 +30,121 @@ export default function App() { ListHeaderComponent={} ListFooterComponent={} /> - + + {/* Threshold indicators - just to visualize the threshold boundaries */} + {/* Top */} + + {/* Bottom */} + + ); } -const InViewPocketMonster = ({ +const PocketMonsterView = ({ name, spriteUri, }: { name: string; spriteUri: string; }) => { - const [isInView, setIsInView] = useState(false); + const [isVisible, setIsVisible] = useState(false); + const [percentVisible, setPercentVisible] = useState(0); - const checkVisible = useCallback( - (isVisible: boolean) => { - setIsInView(isVisible); + useEffect(() => { + console.log( + // eslint-disable-next-line prettier/prettier + `${name} ${isVisible ? 'is now' : "isn't"} visible ${isVisible && !isVisible ? 'anymore' : '' + }` + ); + }, [name, isVisible]); - console.log( - // eslint-disable-next-line prettier/prettier - `${name} ${isVisible ? 'is now' : "isn't"} visible ${isInView && !isVisible ? 'anymore' : '' - }` - ); - }, - [name, isInView] - ); return ( + {/* Header percent visible text - to see % when the bottom is out of viewport */} + + Percent Visible: {percentVisible} + + + {/* Image */} + {/* Name */} {name} + + {/* Footer percent visible text - to see % when the top is out of viewport */} + + Percent Visible: {percentVisible} + ); }; const styles = StyleSheet.create({ - container: { flex: 1 }, + container: { + flex: 1, + }, flatListContentContainer: { alignItems: 'center', }, - spacer: { marginHorizontal: 35 }, + spacer: { + marginHorizontal: 35, + }, + + thresholdIndicatorBase: { + position: 'absolute', + width: '100%', + alignItems: 'center', + zIndex: 9999, + backgroundColor: 'rgba(255, 0, 0, 0.2)', + }, + topThresholdIndicator: { + top: 0, + height: topThreshold, + }, + bottomThresholdIndicator: { + bottom: 0, + height: bottomThreshold, + }, + pocketMonster: { width: 200, height: 200, resizeMode: 'contain', }, - pocketMonsterInView: { + pocketMonsterVisible: { opacity: 1, }, - pocketMonsterNotInView: { + pocketMonsterNotVisible: { opacity: 0.4, }, pocketMonsterName: { @@ -102,22 +153,34 @@ const styles = StyleSheet.create({ marginTop: 16, fontSize: 26, }, + + pocketMonstersPercentVisibilityTextBase: { + textAlign: 'center', + fontSize: 16, + }, + pocketMonsterPercentVisibilityHeader: { + marginBottom: 10, + }, + pocketMonsterPercentVisibilityFooter: { + marginTop: 10, + }, + listHeaderFooter: { - padding: 20, + padding: 40, }, visibilitySensor: { marginBottom: 20, - width: 300, - height: 300, + width: 320, + height: 320, borderRadius: 20, borderWidth: 1, alignItems: 'center', justifyContent: 'center', }, - inView: { + visible: { backgroundColor: 'yellow', }, - notInView: { + notVisible: { backgroundColor: 'pink', opacity: 0.8, }, diff --git a/example/yarn.lock b/example/yarn.lock index 4833216..3d61cbe 100644 --- a/example/yarn.lock +++ b/example/yarn.lock @@ -764,10 +764,10 @@ "@babel/helper-string-parser" "^7.27.1" "@babel/helper-validator-identifier" "^7.27.1" -"@expo/cli@0.24.20": - version "0.24.20" - resolved "https://registry.yarnpkg.com/@expo/cli/-/cli-0.24.20.tgz#c9a3ad7eb93b0d6a39da20ef8804976b65838790" - integrity sha512-uF1pOVcd+xizNtVTuZqNGzy7I6IJon5YMmQidsURds1Ww96AFDxrR/NEACqeATNAmY60m8wy1VZZpSg5zLNkpw== +"@expo/cli@0.24.21": + version "0.24.21" + resolved "https://registry.yarnpkg.com/@expo/cli/-/cli-0.24.21.tgz#16090692059c24d55324060997510cf9e039f9f4" + integrity sha512-DT6K9vgFHqqWL/19mU1ofRcPoO1pn4qmgi76GtuiNU4tbBe/02mRHwFsQw7qRfFAT28If5e/wiwVozgSuZVL8g== dependencies: "@0no-co/graphql.web" "^1.0.8" "@babel/runtime" "^7.20.0" @@ -783,10 +783,11 @@ "@expo/package-manager" "^1.8.6" "@expo/plist" "^0.3.5" "@expo/prebuild-config" "^9.0.11" + "@expo/schema-utils" "^0.1.0" "@expo/spawn-async" "^1.7.2" "@expo/ws-tunnel" "^1.0.1" "@expo/xcpretty" "^4.3.0" - "@react-native/dev-middleware" "0.79.5" + "@react-native/dev-middleware" "0.79.6" "@urql/core" "^5.0.6" "@urql/exchange-retry" "^1.3.0" accepts "^1.3.8" @@ -1031,6 +1032,11 @@ semver "^7.6.0" xml2js "0.6.0" +"@expo/schema-utils@^0.1.0": + version "0.1.0" + resolved "https://registry.yarnpkg.com/@expo/schema-utils/-/schema-utils-0.1.0.tgz#3f7dcfb6c32a03c5535d4748f1fa37f836cd903a" + integrity sha512-Me2avOfbcVT/O5iRmPKLCCSvbCfVfxIstGMlzVJOffplaZX1+ut8D18siR1wx5fkLMTWKs14ozEz11cGUY7hcw== + "@expo/sdk-runtime-versions@^1.0.0": version "1.0.0" resolved "https://registry.yarnpkg.com/@expo/sdk-runtime-versions/-/sdk-runtime-versions-1.0.0.tgz#d7ebd21b19f1c6b0395e50d78da4416941c57f7c" @@ -1284,23 +1290,23 @@ resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== -"@react-native/assets-registry@0.79.3": - version "0.79.3" - resolved "https://registry.yarnpkg.com/@react-native/assets-registry/-/assets-registry-0.79.3.tgz#022218d55a5d9d221a6d176987ab0b35c10d388b" - integrity sha512-Vy8DQXCJ21YSAiHxrNBz35VqVlZPpRYm50xRTWRf660JwHuJkFQG8cUkrLzm7AUriqUXxwpkQHcY+b0ibw9ejQ== - -"@react-native/babel-plugin-codegen@0.79.5": +"@react-native/assets-registry@0.79.5": version "0.79.5" - resolved "https://registry.yarnpkg.com/@react-native/babel-plugin-codegen/-/babel-plugin-codegen-0.79.5.tgz#a2a9fd04fbb28ac75694952c234b159de25b2c52" - integrity sha512-Rt/imdfqXihD/sn0xnV4flxxb1aLLjPtMF1QleQjEhJsTUPpH4TFlfOpoCvsrXoDl4OIcB1k4FVM24Ez92zf5w== + resolved "https://registry.yarnpkg.com/@react-native/assets-registry/-/assets-registry-0.79.5.tgz#90a178ec6646a22eb4218285cc2df7fd82603e34" + integrity sha512-N4Kt1cKxO5zgM/BLiyzuuDNquZPiIgfktEQ6TqJ/4nKA8zr4e8KJgU6Tb2eleihDO4E24HmkvGc73naybKRz/w== + +"@react-native/babel-plugin-codegen@0.79.6": + version "0.79.6" + resolved "https://registry.yarnpkg.com/@react-native/babel-plugin-codegen/-/babel-plugin-codegen-0.79.6.tgz#2e86024a649072268b03b28da8555f9c81bdb51b" + integrity sha512-CS5OrgcMPixOyUJ/Sk/HSsKsKgyKT5P7y3CojimOQzWqRZBmoQfxdST4ugj7n1H+ebM2IKqbgovApFbqXsoX0g== dependencies: "@babel/traverse" "^7.25.3" - "@react-native/codegen" "0.79.5" + "@react-native/codegen" "0.79.6" -"@react-native/babel-preset@0.79.5": - version "0.79.5" - resolved "https://registry.yarnpkg.com/@react-native/babel-preset/-/babel-preset-0.79.5.tgz#2af2d055d4e67c321bf32744b85917490132992b" - integrity sha512-GDUYIWslMLbdJHEgKNfrOzXk8EDKxKzbwmBXUugoiSlr6TyepVZsj3GZDLEFarOcTwH1EXXHJsixihk8DCRQDA== +"@react-native/babel-preset@0.79.6": + version "0.79.6" + resolved "https://registry.yarnpkg.com/@react-native/babel-preset/-/babel-preset-0.79.6.tgz#bc0e94a0b3403d237a60902161587ff90205835c" + integrity sha512-H+FRO+r2Ql6b5IwfE0E7D52JhkxjeGSBSUpCXAI5zQ60zSBJ54Hwh2bBJOohXWl4J+C7gKYSAd2JHMUETu+c/A== dependencies: "@babel/core" "^7.25.2" "@babel/plugin-proposal-export-default-from" "^7.24.7" @@ -1343,15 +1349,15 @@ "@babel/plugin-transform-typescript" "^7.25.2" "@babel/plugin-transform-unicode-regex" "^7.24.7" "@babel/template" "^7.25.0" - "@react-native/babel-plugin-codegen" "0.79.5" + "@react-native/babel-plugin-codegen" "0.79.6" babel-plugin-syntax-hermes-parser "0.25.1" babel-plugin-transform-flow-enums "^0.0.2" react-refresh "^0.14.0" -"@react-native/codegen@0.79.3": - version "0.79.3" - resolved "https://registry.yarnpkg.com/@react-native/codegen/-/codegen-0.79.3.tgz#49689132718c81a3b25426769bc6fd8fd2a0469f" - integrity sha512-CZejXqKch/a5/s/MO5T8mkAgvzCXgsTkQtpCF15kWR9HN8T+16k0CsN7TXAxXycltoxiE3XRglOrZNEa/TiZUQ== +"@react-native/codegen@0.79.5": + version "0.79.5" + resolved "https://registry.yarnpkg.com/@react-native/codegen/-/codegen-0.79.5.tgz#f0f1f82b2603959b8e23711b55eac3dab6490596" + integrity sha512-FO5U1R525A1IFpJjy+KVznEinAgcs3u7IbnbRJUG9IH/MBXi2lEU2LtN+JarJ81MCfW4V2p0pg6t/3RGHFRrlQ== dependencies: glob "^7.1.1" hermes-parser "0.25.1" @@ -1359,23 +1365,25 @@ nullthrows "^1.1.1" yargs "^17.6.2" -"@react-native/codegen@0.79.5": - version "0.79.5" - resolved "https://registry.yarnpkg.com/@react-native/codegen/-/codegen-0.79.5.tgz#f0f1f82b2603959b8e23711b55eac3dab6490596" - integrity sha512-FO5U1R525A1IFpJjy+KVznEinAgcs3u7IbnbRJUG9IH/MBXi2lEU2LtN+JarJ81MCfW4V2p0pg6t/3RGHFRrlQ== +"@react-native/codegen@0.79.6": + version "0.79.6" + resolved "https://registry.yarnpkg.com/@react-native/codegen/-/codegen-0.79.6.tgz#25e9bb68ce02afcdb01b9b2b0bf8a3a7fd99bf8b" + integrity sha512-iRBX8Lgbqypwnfba7s6opeUwVyaR23mowh9ILw7EcT2oLz3RqMmjJdrbVpWhGSMGq2qkPfqAH7bhO8C7O+xfjQ== dependencies: + "@babel/core" "^7.25.2" + "@babel/parser" "^7.25.3" glob "^7.1.1" hermes-parser "0.25.1" invariant "^2.2.4" nullthrows "^1.1.1" yargs "^17.6.2" -"@react-native/community-cli-plugin@0.79.3": - version "0.79.3" - resolved "https://registry.yarnpkg.com/@react-native/community-cli-plugin/-/community-cli-plugin-0.79.3.tgz#84821d3401074e036ba05b8b6ca1ee122cb43e29" - integrity sha512-N/+p4HQqN4yK6IRzn7OgMvUIcrmEWkecglk1q5nj+AzNpfIOzB+mqR20SYmnPfeXF+mZzYCzRANb3KiM+WsSDA== +"@react-native/community-cli-plugin@0.79.5": + version "0.79.5" + resolved "https://registry.yarnpkg.com/@react-native/community-cli-plugin/-/community-cli-plugin-0.79.5.tgz#1cf71637f575a322cdcf6f8b5aeb928aed842508" + integrity sha512-ApLO1ARS8JnQglqS3JAHk0jrvB+zNW3dvNJyXPZPoygBpZVbf8sjvqeBiaEYpn8ETbFWddebC4HoQelDndnrrA== dependencies: - "@react-native/dev-middleware" "0.79.3" + "@react-native/dev-middleware" "0.79.5" chalk "^4.0.0" debug "^2.2.0" invariant "^2.2.4" @@ -1384,23 +1392,23 @@ metro-core "^0.82.0" semver "^7.1.3" -"@react-native/debugger-frontend@0.79.3": - version "0.79.3" - resolved "https://registry.yarnpkg.com/@react-native/debugger-frontend/-/debugger-frontend-0.79.3.tgz#9cb57d8e88c22552194ab5f6f257605b151bc5b3" - integrity sha512-ImNDuEeKH6lEsLXms3ZsgIrNF94jymfuhPcVY5L0trzaYNo9ZFE9Ni2/18E1IbfXxdeIHrCSBJlWD6CTm7wu5A== - "@react-native/debugger-frontend@0.79.5": version "0.79.5" resolved "https://registry.yarnpkg.com/@react-native/debugger-frontend/-/debugger-frontend-0.79.5.tgz#76b8d77b62003b4ea99354fe435c01d727b64584" integrity sha512-WQ49TRpCwhgUYo5/n+6GGykXmnumpOkl4Lr2l2o2buWU9qPOwoiBqJAtmWEXsAug4ciw3eLiVfthn5ufs0VB0A== -"@react-native/dev-middleware@0.79.3": - version "0.79.3" - resolved "https://registry.yarnpkg.com/@react-native/dev-middleware/-/dev-middleware-0.79.3.tgz#3e315ef7516ebad60a4202b4094d84fedecb4064" - integrity sha512-x88+RGOyG71+idQefnQg7wLhzjn/Scs+re1O5vqCkTVzRAc/f7SdHMlbmECUxJPd08FqMcOJr7/X3nsJBrNuuw== +"@react-native/debugger-frontend@0.79.6": + version "0.79.6" + resolved "https://registry.yarnpkg.com/@react-native/debugger-frontend/-/debugger-frontend-0.79.6.tgz#ec0ea9c2f140a564d26789a18dc097519f1b9c48" + integrity sha512-lIK/KkaH7ueM22bLO0YNaQwZbT/oeqhaghOvmZacaNVbJR1Cdh/XAqjT8FgCS+7PUnbxA8B55NYNKGZG3O2pYw== + +"@react-native/dev-middleware@0.79.5": + version "0.79.5" + resolved "https://registry.yarnpkg.com/@react-native/dev-middleware/-/dev-middleware-0.79.5.tgz#8c7b2b790943f24e33a21da39a7c3959ea93304b" + integrity sha512-U7r9M/SEktOCP/0uS6jXMHmYjj4ESfYCkNAenBjFjjsRWekiHE+U/vRMeO+fG9gq4UCcBAUISClkQCowlftYBw== dependencies: "@isaacs/ttlcache" "^1.4.1" - "@react-native/debugger-frontend" "0.79.3" + "@react-native/debugger-frontend" "0.79.5" chrome-launcher "^0.15.2" chromium-edge-launcher "^0.2.0" connect "^3.6.5" @@ -1411,13 +1419,13 @@ serve-static "^1.16.2" ws "^6.2.3" -"@react-native/dev-middleware@0.79.5": - version "0.79.5" - resolved "https://registry.yarnpkg.com/@react-native/dev-middleware/-/dev-middleware-0.79.5.tgz#8c7b2b790943f24e33a21da39a7c3959ea93304b" - integrity sha512-U7r9M/SEktOCP/0uS6jXMHmYjj4ESfYCkNAenBjFjjsRWekiHE+U/vRMeO+fG9gq4UCcBAUISClkQCowlftYBw== +"@react-native/dev-middleware@0.79.6": + version "0.79.6" + resolved "https://registry.yarnpkg.com/@react-native/dev-middleware/-/dev-middleware-0.79.6.tgz#62a4c0b987e5d100eae3e8c95c58ae1c8abe377a" + integrity sha512-BK3GZBa9c7XSNR27EDRtxrgyyA3/mf1j3/y+mPk7Ac0Myu85YNrXnC9g3mL5Ytwo0g58TKrAIgs1fF2Q5Mn6mQ== dependencies: "@isaacs/ttlcache" "^1.4.1" - "@react-native/debugger-frontend" "0.79.5" + "@react-native/debugger-frontend" "0.79.6" chrome-launcher "^0.15.2" chromium-edge-launcher "^0.2.0" connect "^3.6.5" @@ -1428,20 +1436,15 @@ serve-static "^1.16.2" ws "^6.2.3" -"@react-native/gradle-plugin@0.79.3": - version "0.79.3" - resolved "https://registry.yarnpkg.com/@react-native/gradle-plugin/-/gradle-plugin-0.79.3.tgz#69ba47ac406ccdb3b3829f311bd7c27e6fad7ebc" - integrity sha512-imfpZLhNBc9UFSzb/MOy2tNcIBHqVmexh/qdzw83F75BmUtLb/Gs1L2V5gw+WI1r7RqDILbWk7gXB8zUllwd+g== - -"@react-native/js-polyfills@0.79.3": - version "0.79.3" - resolved "https://registry.yarnpkg.com/@react-native/js-polyfills/-/js-polyfills-0.79.3.tgz#bf5614363f118c6bdf2f773c578e603c88d0425c" - integrity sha512-PEBtg6Kox6KahjCAch0UrqCAmHiNLEbp2SblUEoFAQnov4DSxBN9safh+QSVaCiMAwLjvNfXrJyygZz60Dqz3Q== +"@react-native/gradle-plugin@0.79.5": + version "0.79.5" + resolved "https://registry.yarnpkg.com/@react-native/gradle-plugin/-/gradle-plugin-0.79.5.tgz#c2dbdf17a2b724b8f4442a01613c847564503813" + integrity sha512-K3QhfFNKiWKF3HsCZCEoWwJPSMcPJQaeqOmzFP4RL8L3nkpgUwn74PfSCcKHxooVpS6bMvJFQOz7ggUZtNVT+A== -"@react-native/normalize-colors@0.79.3": - version "0.79.3" - resolved "https://registry.yarnpkg.com/@react-native/normalize-colors/-/normalize-colors-0.79.3.tgz#e491937436a2c287707e24263308c818a66eb447" - integrity sha512-T75NIQPRFCj6DFMxtcVMJTZR+3vHXaUMSd15t+CkJpc5LnyX91GVaPxpRSAdjFh7m3Yppl5MpdjV/fntImheYQ== +"@react-native/js-polyfills@0.79.5": + version "0.79.5" + resolved "https://registry.yarnpkg.com/@react-native/js-polyfills/-/js-polyfills-0.79.5.tgz#61b6c43832b644669d1f00dbbaa51a079c5b9b4c" + integrity sha512-a2wsFlIhvd9ZqCD5KPRsbCQmbZi6KxhRN++jrqG0FUTEV5vY7MvjjUqDILwJd2ZBZsf7uiDuClCcKqA+EEdbvw== "@react-native/normalize-colors@0.79.5": version "0.79.5" @@ -1453,10 +1456,10 @@ resolved "https://registry.yarnpkg.com/@react-native/normalize-colors/-/normalize-colors-0.74.89.tgz#b8ac17d1bbccd3ef9a1f921665d04d42cff85976" integrity sha512-qoMMXddVKVhZ8PA1AbUCk83trpd6N+1nF2A6k1i6LsQObyS92fELuk8kU/lQs6M7BsMHwqyLCpQJ1uFgNvIQXg== -"@react-native/virtualized-lists@0.79.3": - version "0.79.3" - resolved "https://registry.yarnpkg.com/@react-native/virtualized-lists/-/virtualized-lists-0.79.3.tgz#4a2799017cd3795f519422f48b3c0bbc4739a245" - integrity sha512-/0rRozkn+iIHya2vnnvprDgT7QkfI54FLrACAN3BLP7MRlfOIGOrZsXpRLndnLBVnjNzkcre84i1RecjoXnwIA== +"@react-native/virtualized-lists@0.79.5": + version "0.79.5" + resolved "https://registry.yarnpkg.com/@react-native/virtualized-lists/-/virtualized-lists-0.79.5.tgz#5dbc01dcb4c836d40edcb4034b240a300ee310fb" + integrity sha512-EUPM2rfGNO4cbI3olAbhPkIt3q7MapwCwAJBzUfWlZ/pu0PRNOnMQ1IvaXTf3TpeozXV52K1OdprLEI/kI5eUA== dependencies: invariant "^2.2.4" nullthrows "^1.1.1" @@ -2221,10 +2224,10 @@ babel-preset-current-node-syntax@^1.0.0: "@babel/plugin-syntax-private-property-in-object" "^7.14.5" "@babel/plugin-syntax-top-level-await" "^7.14.5" -babel-preset-expo@~13.2.3: - version "13.2.3" - resolved "https://registry.yarnpkg.com/babel-preset-expo/-/babel-preset-expo-13.2.3.tgz#d96d2df314a9e20d5218a0642e6541c4735f3a34" - integrity sha512-wQJn92lqj8GKR7Ojg/aW4+GkqI6ZdDNTDyOqhhl7A9bAqk6t0ukUOWLDXQb4p0qKJjMDV1F6gNWasI2KUbuVTQ== +babel-preset-expo@~13.2.4: + version "13.2.4" + resolved "https://registry.yarnpkg.com/babel-preset-expo/-/babel-preset-expo-13.2.4.tgz#ad31bbfc8b3169a5a61108cebdee5350feebc071" + integrity sha512-3IKORo3KR+4qtLdCkZNDj8KeA43oBn7RRQejFGWfiZgu/NeaRUSri8YwYjZqybm7hn3nmMv9OLahlvXBX23o5Q== dependencies: "@babel/helper-module-imports" "^7.25.9" "@babel/plugin-proposal-decorators" "^7.12.9" @@ -2240,7 +2243,7 @@ babel-preset-expo@~13.2.3: "@babel/plugin-transform-runtime" "^7.24.7" "@babel/preset-react" "^7.22.15" "@babel/preset-typescript" "^7.23.0" - "@react-native/babel-preset" "0.79.5" + "@react-native/babel-preset" "0.79.6" babel-plugin-react-native-web "~0.19.13" babel-plugin-syntax-hermes-parser "^0.25.1" babel-plugin-transform-flow-enums "^0.0.2" @@ -3339,19 +3342,19 @@ expo-pwa@0.0.127: commander "2.20.0" update-check "1.5.3" -expo@^53.0.20: - version "53.0.20" - resolved "https://registry.yarnpkg.com/expo/-/expo-53.0.20.tgz#e11b553322de313d47c407367d8d48d3a073e3cc" - integrity sha512-Nh+HIywVy9KxT/LtH08QcXqrxtUOA9BZhsXn3KCsAYA+kNb80M8VKN8/jfQF+I6CgeKyFKJoPNsWgI0y0VBGrA== +expo@^53.0.22: + version "53.0.22" + resolved "https://registry.yarnpkg.com/expo/-/expo-53.0.22.tgz#ff61b6bcdf0855b7b88ca5ca0f622e12cbdb1d0f" + integrity sha512-sJ2I4W/e5iiM4u/wYCe3qmW4D7WPCRqByPDD0hJcdYNdjc9HFFFdO4OAudZVyC/MmtoWZEIH5kTJP1cw9FjzYA== dependencies: "@babel/runtime" "^7.20.0" - "@expo/cli" "0.24.20" + "@expo/cli" "0.24.21" "@expo/config" "~11.0.13" "@expo/config-plugins" "~10.1.2" "@expo/fingerprint" "0.13.4" "@expo/metro-config" "0.20.17" "@expo/vector-icons" "^14.0.0" - babel-preset-expo "~13.2.3" + babel-preset-expo "~13.2.4" expo-asset "~11.1.7" expo-constants "~17.1.7" expo-file-system "~18.1.11" @@ -5809,19 +5812,19 @@ react-native-web@^0.20.0: postcss-value-parser "^4.2.0" styleq "^0.1.3" -react-native@0.79.3: - version "0.79.3" - resolved "https://registry.yarnpkg.com/react-native/-/react-native-0.79.3.tgz#16580ca202016c75e3c61116fcfe3b30f6d762fc" - integrity sha512-EzH1+9gzdyEo9zdP6u7Sh3Jtf5EOMwzy+TK65JysdlgAzfEVfq4mNeXcAZ6SmD+CW6M7ARJbvXLyTD0l2S5rpg== +react-native@0.79.5: + version "0.79.5" + resolved "https://registry.yarnpkg.com/react-native/-/react-native-0.79.5.tgz#a91cd92bb282a4f8420fdd64fe3a9434580404b2" + integrity sha512-jVihwsE4mWEHZ9HkO1J2eUZSwHyDByZOqthwnGrVZCh6kTQBCm4v8dicsyDa6p0fpWNE5KicTcpX/XXl0ASJFg== dependencies: "@jest/create-cache-key-function" "^29.7.0" - "@react-native/assets-registry" "0.79.3" - "@react-native/codegen" "0.79.3" - "@react-native/community-cli-plugin" "0.79.3" - "@react-native/gradle-plugin" "0.79.3" - "@react-native/js-polyfills" "0.79.3" - "@react-native/normalize-colors" "0.79.3" - "@react-native/virtualized-lists" "0.79.3" + "@react-native/assets-registry" "0.79.5" + "@react-native/codegen" "0.79.5" + "@react-native/community-cli-plugin" "0.79.5" + "@react-native/gradle-plugin" "0.79.5" + "@react-native/js-polyfills" "0.79.5" + "@react-native/normalize-colors" "0.79.5" + "@react-native/virtualized-lists" "0.79.5" abort-controller "^3.0.0" anser "^1.4.9" ansi-regex "^5.0.0" diff --git a/package.json b/package.json index 49c7c18..f09955c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@futurejj/react-native-visibility-sensor", - "version": "1.3.22", + "version": "1.4.0-beta.3", "description": "A React Native wrapper to check whether a component is in the view port to track impressions and clicks", "main": "lib/commonjs/index", "module": "lib/module/index", diff --git a/release.config.js b/release.config.js index bf74e46..7b6185e 100644 --- a/release.config.js +++ b/release.config.js @@ -1,10 +1,35 @@ module.exports = { - branches: ['main'], + branches: [ + 'main', + { + name: 'beta', + prerelease: true, // Marks this as a prerelease channel + }, + ], plugins: [ - '@semantic-release/commit-analyzer', // Analyzes commits for version bumping + [ + '@semantic-release/commit-analyzer', + { + preset: 'angular', + releaseRules: [ + { type: 'chore', scope: 'deps', release: 'patch' }, + { type: 'chore', breaking: true, release: 'major' }, + { type: 'chore', scope: 'breaking', release: 'major' }, + ], + parserOpts: { + noteKeywords: ['BREAKING CHANGE', 'BREAKING CHANGES'], + }, + }, + ], '@semantic-release/release-notes-generator', // Generates release notes '@semantic-release/changelog', // Generates the changelog - '@semantic-release/npm', // Handles npm publishing + [ + '@semantic-release/npm', + { + npmPublish: true, + tag: 'beta', // Publishes with a 'beta' tag to npm + }, + ], '@semantic-release/github', // Handles GitHub releases [ '@semantic-release/git', diff --git a/src/VisibilitySensor.tsx b/src/VisibilitySensor.tsx index 2d48b72..3afeba2 100644 --- a/src/VisibilitySensor.tsx +++ b/src/VisibilitySensor.tsx @@ -6,13 +6,19 @@ import React, { forwardRef, useImperativeHandle, } from 'react'; -import { Dimensions, type ScaledSize, View } from 'react-native'; +import { useWindowDimensions, View } from 'react-native'; import type { VisibilitySensorRef, VisibilitySensorProps, RectDimensionsState, } from './visibilitySensor.types'; +enum MeasurementState { + IDLE = 'IDLE', // Not yet measured + MEASURING = 'MEASURING', // Measurement in progress + MEASURED = 'MEASURED', // Has valid measurements +} + function useInterval(callback: () => void, delay: number | null) { const savedCallback = useRef(callback); @@ -34,6 +40,7 @@ const VisibilitySensor = forwardRef( (props, ref) => { const { onChange, + onPercentChange, disabled = false, triggerOnce = false, delay, @@ -42,12 +49,17 @@ const VisibilitySensor = forwardRef( ...rest } = props; - const localRef = useRef(null); - useImperativeHandle(ref, () => ({ getInnerRef: () => localRef.current, })); + const window = useWindowDimensions(); + + const localRef = useRef(null); + const isMountedRef = useRef(true); + const measurementStateRef = useRef(MeasurementState.IDLE); + const lastPercentRef = useRef(undefined); + const [rectDimensions, setRectDimensions] = useState({ rectTop: 0, rectBottom: 0, @@ -58,12 +70,19 @@ const VisibilitySensor = forwardRef( }); const [lastValue, setLastValue] = useState(undefined); const [active, setActive] = useState(false); - const hasMeasuredRef = useRef(false); - const measureInnerView = () => { + const measureInnerView = useCallback(() => { /* Check if the sensor is active to prevent unnecessary measurements This avoids running measurements when the sensor is disabled or stopped */ - if (!active) return; + if ( + !active || + !isMountedRef.current || + measurementStateRef.current === MeasurementState.MEASURING + ) { + return; + } + + measurementStateRef.current = MeasurementState.MEASURING; localRef.current?.measure( ( @@ -74,6 +93,11 @@ const VisibilitySensor = forwardRef( pageX: number, pageY: number ) => { + // Check if component is still mounted before setting state because measurement can be asynchronous + if (!isMountedRef.current) { + return; + } + const dimensions = { rectTop: pageY, rectBottom: pageY + height, @@ -91,13 +115,14 @@ const VisibilitySensor = forwardRef( rectDimensions.rectHeight !== dimensions.rectHeight ) { setRectDimensions(dimensions); - /* Set hasMeasuredRef to true to indicate that a valid measurement has been taken - This ensures visibility checks only proceed after initial measurement */ - hasMeasuredRef.current = true; } + + /* Set measurementStateRef to MEASURED to indicate that a valid measurement has + been taken. This ensures visibility checks only proceed after initial measurement */ + measurementStateRef.current = MeasurementState.MEASURED; } ); - }; + }, [active, rectDimensions]); useInterval(measureInnerView, delay || 100); @@ -110,10 +135,44 @@ const VisibilitySensor = forwardRef( setActive(false); /* Reset measurement state when stopping to ensure fresh measurements when the sensor is reactivated */ - hasMeasuredRef.current = false; + measurementStateRef.current = MeasurementState.IDLE; // Reset state } }, [active]); + // Effect to trigger initial measurement when component becomes active: + useEffect(() => { + let timer: ReturnType; + + if (active && measurementStateRef.current === MeasurementState.IDLE) { + // Use setTimeout with 0 delay to ensure layout is complete + timer = setTimeout(() => { + measureInnerView(); + }, 0); + } + + return () => { + if (timer) clearTimeout(timer); + }; + }, [active, measureInnerView]); + + // Reset measurement state when dimensions change: + useEffect(() => { + if ( + isMountedRef.current && + measurementStateRef.current === MeasurementState.MEASURED + ) { + // Reset measurement state to force remeasurement with new dimensions + measurementStateRef.current = MeasurementState.IDLE; + } + }, [window]); + + useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + }; + }, []); + useEffect(() => { if (!disabled) { startWatching(); @@ -128,15 +187,70 @@ const VisibilitySensor = forwardRef( /* Ensure visibility checks only run when the sensor is active and at least one measurement has been completed. This prevents premature visibility calculations with invalid or stale dimensions */ - if (!active || !hasMeasuredRef.current) return; + if ( + !active || + measurementStateRef.current !== MeasurementState.MEASURED || + !isMountedRef.current + ) { + return; + } - const window: ScaledSize = Dimensions.get('window'); const isVisible: boolean = rectDimensions.rectTop + (threshold.top || 0) <= window.height && // Top edge is within the bottom of the window rectDimensions.rectBottom - (threshold.bottom || 0) >= 0 && // Bottom edge is within the top of the window rectDimensions.rectLeft + (threshold.left || 0) <= window.width && // Left edge is within the right of the window rectDimensions.rectRight - (threshold.right || 0) >= 0; // Right edge is within the left of the window + // Calculate percent visible if callback is requested / provided + if ( + onPercentChange && + rectDimensions.rectWidth > 0 && + rectDimensions.rectHeight > 0 + ) { + let percentVisible = 0; + + // Don't perform % calculation if not visible for efficiency + if (isVisible) { + // Thresholds reduce the effective viewport + const viewportTop = 0 + (threshold.top || 0); + const viewportBottom = window.height - (threshold.bottom || 0); + const viewportLeft = 0 + (threshold.left || 0); + const viewportRight = window.width - (threshold.right || 0); + + // Calculate the visible portion of the element within the reduced viewport + const visibleTop = Math.max(viewportTop, rectDimensions.rectTop); + const visibleBottom = Math.min( + viewportBottom, + rectDimensions.rectBottom + ); + const visibleLeft = Math.max(viewportLeft, rectDimensions.rectLeft); + const visibleRight = Math.min( + viewportRight, + rectDimensions.rectRight + ); + + // Calculate visible dimensions + const visibleHeight = Math.max(0, visibleBottom - visibleTop); + const visibleWidth = Math.max(0, visibleRight - visibleLeft); + + // Calculate percent visible based on actual element area + const visibleArea = visibleHeight * visibleWidth; + const totalArea = + rectDimensions.rectHeight * rectDimensions.rectWidth; + percentVisible = + totalArea > 0 ? Math.round((visibleArea / totalArea) * 100) : 0; + } else { + // when !isVisible + percentVisible = 0; // No need to calculate, it's fully out of view, so 0% + } + + // Only fire callback if percent has changed + if (lastPercentRef.current !== percentVisible) { + lastPercentRef.current = percentVisible; // Update last reported percent + onPercentChange(percentVisible); + } + } + if (lastValue !== isVisible) { setLastValue(isVisible); onChange(isVisible); @@ -144,8 +258,17 @@ const VisibilitySensor = forwardRef( stopWatching(); } } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [rectDimensions, lastValue, active]); + }, [ + rectDimensions, + window, + lastValue, + active, + onPercentChange, + threshold, + onChange, + triggerOnce, + stopWatching, + ]); return ( diff --git a/src/visibilitySensor.types.ts b/src/visibilitySensor.types.ts index 4b9e791..2780d9e 100644 --- a/src/visibilitySensor.types.ts +++ b/src/visibilitySensor.types.ts @@ -2,6 +2,7 @@ import type { View, ViewProps } from 'react-native'; export interface VisibilitySensorProps extends ViewProps { onChange: (visible: boolean) => void; + onPercentChange?: (percentVisible: number) => void; disabled?: boolean; triggerOnce?: boolean; delay?: number | undefined;