├── .babelrc ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .jest.ci.js ├── .jest.js ├── .npmignore ├── .vscode ├── launch.json └── settings.json ├── LICENSE ├── README.md ├── eslint.config.mjs ├── example.gif ├── package.json ├── renovate.json ├── scripts └── ensure-publish-path.js ├── src ├── __snapshots__ │ ├── connect-time.test.tsx.snap │ ├── countdown.test.tsx.snap │ ├── time-provider.test.tsx.snap │ └── timed.test.tsx.snap ├── connect-time.test.tsx ├── connect-time.tsx ├── context.ts ├── countdown.test.tsx ├── countdown.tsx ├── index.test.ts ├── index.ts ├── time-provider.test.tsx ├── time-provider.tsx ├── timed.test.tsx ├── timed.tsx ├── use-countdown.test.tsx ├── use-countdown.ts ├── use-time.test.tsx └── use-time.ts ├── test ├── mock-time-provider.tsx └── util.ts ├── tsconfig.build.json ├── tsconfig.json └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-typescript", 4 | "@babel/preset-react", 5 | [ 6 | "@babel/preset-env", 7 | { 8 | "targets": "> 0.25%, not dead, iOS 9" 9 | } 10 | ] 11 | ], 12 | "plugins": ["@babel/plugin-proposal-class-properties"] 13 | } 14 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: react-time-sync 2 | on: 3 | - push 4 | 5 | jobs: 6 | build: 7 | name: build 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | - name: Cache global yarn cache 12 | uses: actions/cache@v4 13 | with: 14 | path: /home/runner/.cache/yarn 15 | key: ${{ runner.OS }}-build-yarn-${{ hashFiles('yarn.lock') }} 16 | restore-keys: | 17 | ${{ runner.OS }}-build-yarn-${{ env.cache-name }}- 18 | ${{ runner.OS }}-build-yarn- 19 | - name: Cache dependencies 20 | uses: actions/cache@v4 21 | with: 22 | path: node_modules 23 | key: ${{ runner.OS }}-build-${{ hashFiles('yarn.lock') }} 24 | restore-keys: | 25 | ${{ runner.OS }}-build-${{ env.cache-name }}- 26 | ${{ runner.OS }}-build- 27 | ${{ runner.OS }}- 28 | - name: Install dependencies 29 | run: yarn --frozen-lockfile 30 | - name: Lint 31 | run: yarn lint 32 | - name: Test 33 | run: yarn test-ci 34 | env: 35 | TZ: Europe/Berlin 36 | - name: Build 37 | run: yarn build 38 | - name: Upload test coverage information 39 | run: bash <(curl -s https://codecov.io/bash) 40 | - name: Upload build 41 | uses: actions/upload-artifact@v4 42 | with: 43 | name: build 44 | path: build 45 | release: 46 | name: release 47 | needs: build 48 | if: github.ref == 'refs/heads/main' 49 | runs-on: ubuntu-latest 50 | steps: 51 | - uses: actions/checkout@main 52 | - name: Download build 53 | uses: actions/download-artifact@v4 54 | with: 55 | name: build 56 | path: build 57 | - name: Release 58 | run: npx semantic-release@19 --branches main 59 | working-directory: build 60 | env: 61 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 62 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 63 | notify-on-failure: 64 | needs: 65 | - build 66 | - release 67 | runs-on: ubuntu-latest 68 | if: failure() # only run this job when any of the previous jobs fail. 69 | steps: 70 | - name: Notify through commit comment 71 | uses: peter-evans/commit-comment@v3 72 | with: 73 | body: "@peterjuras: The workflow failed!" 74 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # OS X 9 | .DS_Store 10 | 11 | # Runtime data 12 | pids 13 | *.pid 14 | *.seed 15 | *.pid.lock 16 | 17 | # Directory for instrumented libs generated by jscoverage/JSCover 18 | lib-cov 19 | 20 | # Coverage directory used by tools like istanbul 21 | coverage 22 | 23 | # nyc test coverage 24 | .nyc_output 25 | 26 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 27 | .grunt 28 | 29 | # Bower dependency directory (https://bower.io/) 30 | bower_components 31 | 32 | # node-waf configuration 33 | .lock-wscript 34 | 35 | # Compiled binary addons (http://nodejs.org/api/addons.html) 36 | build/Release 37 | 38 | # Dependency directories 39 | node_modules/ 40 | jspm_packages/ 41 | 42 | # Typescript v1 declaration files 43 | typings/ 44 | 45 | # Optional npm cache directory 46 | .npm 47 | 48 | # Optional eslint cache 49 | .eslintcache 50 | 51 | # Optional REPL history 52 | .node_repl_history 53 | 54 | # Output of 'npm pack' 55 | *.tgz 56 | 57 | # Yarn Integrity file 58 | .yarn-integrity 59 | 60 | # dotenv environment variables file 61 | .env 62 | 63 | # Build output 64 | /build 65 | -------------------------------------------------------------------------------- /.jest.ci.js: -------------------------------------------------------------------------------- 1 | const config = require("./.jest.js"); 2 | 3 | module.exports = { 4 | ...config, 5 | coverageReporters: ["lcov"], 6 | }; 7 | -------------------------------------------------------------------------------- /.jest.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ["/src"], 3 | collectCoverage: true, 4 | collectCoverageFrom: ["src/**/*.ts", "src/**/*.tsx"], 5 | coverageReporters: ["text"], 6 | testEnvironment: "jsdom", 7 | testRunner: "jest-circus/runner", 8 | }; 9 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /scripts 2 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "type": "node", 6 | "request": "launch", 7 | "name": "Launch Jest", 8 | "program": "${workspaceRoot}/node_modules/jest/bin/jest.js", 9 | "cwd": "${workspaceRoot}", 10 | "args": ["--runInBand"], 11 | "runtimeArgs": ["--nolazy"], 12 | "env": { 13 | "NODE_ENV": "development" 14 | }, 15 | "sourceMaps": true 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules\\typescript\\lib", 3 | "jest.jestCommandLine": "npm test --" 4 | } 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Peter Juras 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-time-sync 2 | 3 | [![npm (scoped)](https://img.shields.io/npm/v/react-time-sync.svg)](https://www.npmjs.com/package/react-time-sync) [![Actions Status](https://github.com/peterjuras/react-time-sync/workflows/react-time-sync/badge.svg)](https://github.com/peterjuras/react-time-sync/actions) [![codecov](https://codecov.io/gh/peterjuras/react-time-sync/branch/main/graph/badge.svg?token=S1CcXL4OuS)](https://codecov.io/gh/peterjuras/react-time-sync) [![Renovate](https://img.shields.io/badge/renovate-enabled-brightgreen.svg)](https://renovatebot.com) [![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](https://github.com/semantic-release/semantic-release) 4 | 5 | A React library to synchronize timers across an application. Requires React v16.8 or higher. 6 | 7 | Watch [my talk from React Day Berlin](https://youtu.be/3uH90g1Q5iY) to understand why you might need it. 8 | 9 | ![Example](example.gif) 10 | 11 | ## Usage 12 | 13 | ### useCountdown hook 14 | 15 | A custom hook which returns the time left until a certain millisecond UTC timestamp is reached 16 | 17 | Example: 18 | 19 | ```js 20 | import { useCountdown } from "react-time-sync"; 21 | 22 | const MyComponent = ({ until }) => { 23 | const timeLeft = useCountdown({ until }); 24 | return
{timeLeft > 0 ? `${timeLeft} seconds left` : "Done!"}
; 25 | }; 26 | ``` 27 | 28 | #### Input 29 | 30 | The `useCountdown` hook expects an object with the following properties as the single argument: 31 | 32 | `until` - A UTC millisecond timestamp until when the countdown should run (default: 0) 33 | 34 | `interval` - one of `TimeSync.SECONDS`, `TimeSync.MINUTES`, `TimeSync.HOURS`, `TimeSync.DAYS` (default: `TimeSync.SECONDS`) 35 | 36 | ### useTime hook 37 | 38 | A custom hook which returns the current time rounded to the specified interval 39 | 40 | Example: 41 | 42 | ```js 43 | import { useTime } from "react-time-sync"; 44 | 45 | const MyComponent = () => { 46 | const currentTime = useTime(); 47 | return
{`The current time is: ${currentTime}`}
; 48 | }; 49 | ``` 50 | 51 | #### Input 52 | 53 | The `useTime` hook expects an object with the following properties as the single argument: 54 | 55 | `unit` - The number of units of `interval` (default: `1`) 56 | 57 | `interval` - one of `TimeSync.SECONDS`, `TimeSync.MINUTES`, `TimeSync.HOURS`, `TimeSync.DAYS` (default: `TimeSync.SECONDS`) 58 | 59 | ### Countdown 60 | 61 | A component that accepts render props to periodically re-render its children with the time left until a certain millisecond UTC timestamp 62 | 63 | Example: 64 | 65 | ```js 66 | import { Countdown } from 'react-time-sync'; 67 | 68 | const MyComponent = ({ until }) => { 69 | return ( 70 | 71 | {({ timeLeft }) => ( 72 |
{timeLeft > 0 ? `${timeLeft} seconds left` : 'Done!'}
73 | )} 74 |
75 | ) 76 | } 77 | 78 | const until = Date.now() + 5000; 79 | 80 | ReactDOM.render(, ...); 81 | ``` 82 | 83 | #### Props 84 | 85 | `until` - A UTC millisecond timestamp until when the countdown should run (required) 86 | 87 | `interval` - one of `TimeSync.SECONDS`, `TimeSync.MINUTES`, `TimeSync.HOURS`, `TimeSync.DAYS` (default: `TimeSync.SECONDS`) 88 | 89 | ### Timed 90 | 91 | A component that accepts render props to periodically re-render its children with the current time rounded to the specified interval 92 | 93 | Example: 94 | 95 | ```js 96 | import { Timed } from "react-time-sync"; 97 | 98 | const MyComponent = () => { 99 | return ( 100 | 101 | {({ currentTime }) =>
{`The current time is: ${currentTime}`}
} 102 |
103 | ); 104 | }; 105 | ``` 106 | 107 | #### Props 108 | 109 | `unit` - The number of units of `interval` (default: `1`) 110 | 111 | `interval` - one of `TimeSync.SECONDS`, `TimeSync.MINUTES`, `TimeSync.HOURS`, `TimeSync.DAYS` (default: `TimeSync.SECONDS`) 112 | 113 | ### connectTime()() 114 | 115 | A higher order component meant to be used in combination with redux. 116 | 117 | Example: 118 | 119 | ```js 120 | import { connectTime, SECONDS } from "react-time-sync"; 121 | 122 | const timeSlotsSelector = createSelector( 123 | (currentTime) => currentTime, 124 | (currentTime) => [currentTime - 1, currentTime + 1], 125 | ); 126 | 127 | function mapStateToProps({ currentTime }) { 128 | const timeSlots = timeSlotSelectors(currentTime); 129 | return { 130 | timeSlots, 131 | }; 132 | } 133 | 134 | const timerConfig = { 135 | unit: 1, 136 | interval: SECONDS, 137 | }; 138 | 139 | export default connectTime(timerConfig)(connect(mapStateToProps)(MyComponent)); 140 | ``` 141 | 142 | #### timerConfig properties 143 | 144 | `unit` - The number of units of `interval` (default: `1`) 145 | 146 | `interval` - one of `TimeSync.SECONDS`, `TimeSync.MINUTES`, `TimeSync.HOURS`, `TimeSync.DAYS` (default: `TimeSync.SECONDS`) 147 | 148 | ### TimeProvider 149 | 150 | You can use a `` component to use a custom instance of `TimeSync`, e.g. when you want to synchronize timers across your application 151 | 152 | Example: 153 | 154 | ```js 155 | import { useState } from "react"; 156 | import { TimeProvider } from "react-time-sync"; 157 | import TimeSync from "time-sync"; 158 | 159 | const App = ({ children }) => { 160 | const [timeSync] = useState(() => new TimeSync()); 161 | return ( 162 |
163 | {children} 164 |
165 | ); 166 | }; 167 | ``` 168 | 169 | #### Props 170 | 171 | `timeSync` - A custom `TimeSync` instance that should be passed down with the context. (default: `new TimeSync()`) 172 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import typescriptEslint from "@typescript-eslint/eslint-plugin"; 2 | import globals from "globals"; 3 | import tsParser from "@typescript-eslint/parser"; 4 | import path from "node:path"; 5 | import { fileURLToPath } from "node:url"; 6 | import js from "@eslint/js"; 7 | import { FlatCompat } from "@eslint/eslintrc"; 8 | 9 | const __filename = fileURLToPath(import.meta.url); 10 | const __dirname = path.dirname(__filename); 11 | const compat = new FlatCompat({ 12 | baseDirectory: __dirname, 13 | recommendedConfig: js.configs.recommended, 14 | allConfig: js.configs.all 15 | }); 16 | 17 | export default [...compat.extends( 18 | "eslint:recommended", 19 | "plugin:@typescript-eslint/eslint-recommended", 20 | "plugin:@typescript-eslint/recommended", 21 | "plugin:react/recommended", 22 | "prettier", 23 | ), { 24 | plugins: { 25 | "@typescript-eslint": typescriptEslint, 26 | }, 27 | 28 | languageOptions: { 29 | globals: { 30 | ...globals.node, 31 | }, 32 | 33 | parser: tsParser, 34 | }, 35 | 36 | settings: { 37 | react: { 38 | version: "detect", 39 | }, 40 | }, 41 | }, { 42 | files: ["**/*.test.ts", "**/*.test.tsx", "test/**/*.tsx"], 43 | 44 | languageOptions: { 45 | globals: { 46 | ...globals.jest, 47 | fail: true, 48 | }, 49 | }, 50 | 51 | rules: { 52 | "@typescript-eslint/no-explicit-any": 0, 53 | "@typescript-eslint/no-var-requires": 0, 54 | "@typescript-eslint/explicit-function-return-type": 0, 55 | }, 56 | }, { 57 | files: ["**/*.js"], 58 | 59 | rules: { 60 | "@typescript-eslint/no-var-requires": 0, 61 | "no-console": 0, 62 | }, 63 | }]; -------------------------------------------------------------------------------- /example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterjuras/react-time-sync/94b1e8308704b163dd9767d7042ce564843a0a9c/example.gif -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-time-sync", 3 | "version": "0.0.0-development", 4 | "description": "A React library to synchronize timers across an application", 5 | "main": "index.js", 6 | "types": "index.d.ts", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/peterjuras/react-time-sync.git" 10 | }, 11 | "author": "Peter Juras ", 12 | "scripts": { 13 | "build:typedefs": "tsc -p tsconfig.build.json --declaration --emitDeclarationOnly", 14 | "build:ts": "babel src -d build --extensions '.ts,.tsx' --ignore '**/*.test.ts,**/*.test.tsx'", 15 | "build": "rm -rf build && run-p build:ts build:typedefs && cp -R scripts .npmignore LICENSE package.json README.md build", 16 | "prepublishOnly": "node ./scripts/ensure-publish-path", 17 | "lint:eslint": "eslint '{scripts,src,test}/**/*.{ts,tsx,js}' --max-warnings 0", 18 | "lint:tsc": "tsc", 19 | "lint:prettier": "prettier --check scripts src test", 20 | "lint": "run-p lint:eslint lint:tsc lint:prettier", 21 | "test": "jest --config .jest.js", 22 | "test-ci": "jest --ci --config .jest.ci.js" 23 | }, 24 | "license": "MIT", 25 | "devDependencies": { 26 | "@babel/cli": "7.27.2", 27 | "@babel/core": "7.27.4", 28 | "@babel/plugin-proposal-class-properties": "7.18.6", 29 | "@babel/preset-env": "7.27.2", 30 | "@babel/preset-react": "7.27.1", 31 | "@babel/preset-typescript": "7.27.1", 32 | "@sinonjs/fake-timers": "14.0.0", 33 | "@testing-library/dom": "10.4.0", 34 | "@testing-library/react": "16.3.0", 35 | "@testing-library/react-hooks": "8.0.1", 36 | "@types/jest": "29.5.14", 37 | "@types/react": "18.3.23", 38 | "@types/sinonjs__fake-timers": "8.1.5", 39 | "@typescript-eslint/eslint-plugin": "8.33.1", 40 | "@typescript-eslint/parser": "8.33.1", 41 | "babel-jest": "29.7.0", 42 | "eslint": "9.28.0", 43 | "eslint-config-prettier": "10.1.5", 44 | "eslint-plugin-react": "7.37.5", 45 | "jest": "29.7.0", 46 | "jest-circus": "29.7.0", 47 | "jest-environment-jsdom": "29.7.0", 48 | "npm-run-all2": "8.0.4", 49 | "prettier": "3.5.3", 50 | "react": "18.3.1", 51 | "react-dom": "18.3.1", 52 | "react-test-renderer": "18.3.1", 53 | "typescript": "5.8.3" 54 | }, 55 | "peerDependencies": { 56 | "react": "> 16.8.0" 57 | }, 58 | "dependencies": { 59 | "prop-types": "^15.7.2", 60 | "time-sync": "^2.3.0", 61 | "use-state-with-deps": "^1.1.0" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["config:base", "group:allNonMajor"], 3 | "schedule": "on Friday", 4 | "postUpdateOptions": ["yarnDedupeHighest"], 5 | "lockFileMaintenance": { 6 | "enabled": true, 7 | "automerge": true, 8 | "automergeType": "branch" 9 | }, 10 | "packageRules": [ 11 | { 12 | "matchUpdateTypes": ["minor", "patch"], 13 | "matchCurrentVersion": "!/^0/", 14 | "automerge": true, 15 | "automergeType": "branch" 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /scripts/ensure-publish-path.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-require-imports 2 | const path = require("path"); 3 | 4 | const splitted = process.cwd().split(path.sep); 5 | 6 | if (splitted[splitted.length - 1] !== "build") { 7 | console.log( 8 | "ERROR! You can only publish outside of the transpiled /build folder\n", 9 | ); 10 | process.exit(1); 11 | } 12 | -------------------------------------------------------------------------------- /src/__snapshots__/connect-time.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`#connectTime should be reusable for multiple components 1`] = ` 4 | 5 |
6 | Child 1: 0 7 |
8 |
9 | Child 2: 0 10 |
11 |
12 | Child 3: 0 13 |
14 |
15 | `; 16 | 17 | exports[`#connectTime should throw for invalid timeProp 1`] = `"timeProp must be a non-empty string value"`; 18 | 19 | exports[`#connectTime should throw for invalid timeProp 2`] = `"timeProp must be a non-empty string value"`; 20 | 21 | exports[`#connectTime should throw for invalid timeProp 3`] = `"timeProp must be a non-empty string value"`; 22 | 23 | exports[`#connectTime should throw for invalid timeProp 4`] = `"timeProp must be a non-empty string value"`; 24 | 25 | exports[`#connectTime should throw for invalid timeProp 5`] = `"timeProp must be a non-empty string value"`; 26 | 27 | exports[`#connectTime should throw for invalid timeProp 6`] = `"timeProp must be a non-empty string value"`; 28 | 29 | exports[`#connectTime should throw for invalid timeProp 7`] = `"timeProp must be a non-empty string value"`; 30 | 31 | exports[`#connectTime should update children on a timer tick 1`] = ` 32 | 33 |
34 | 0 35 |
36 |
37 | `; 38 | 39 | exports[`#connectTime should update children on a timer tick 2`] = ` 40 | 41 |
42 | 1 43 |
44 |
45 | `; 46 | -------------------------------------------------------------------------------- /src/__snapshots__/countdown.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`#Countdown should not update when until number is reached 1`] = ` 4 | 5 |
6 | 0 7 |
8 |
9 | `; 10 | 11 | exports[`#Countdown should not update when until number is reached 2`] = ` 12 | 13 |
14 | 0 15 |
16 |
17 | `; 18 | 19 | exports[`#Countdown should stop countdown if it has ended 1`] = ` 20 | 21 |
22 | 6 23 |
24 |
25 | `; 26 | 27 | exports[`#Countdown should stop countdown if it has ended 2`] = ` 28 | 29 |
30 | 0 31 |
32 |
33 | `; 34 | -------------------------------------------------------------------------------- /src/__snapshots__/time-provider.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`#TimeProvider should accept default ReactProps.children type as children 1`] = ` 4 | 5 |
6 | Test1 7 |
8 |
9 | Test2 10 |
11 |
12 | `; 13 | 14 | exports[`#TimeProvider should render a single child 1`] = ` 15 | 16 |
17 | Test 18 |
19 |
20 | `; 21 | 22 | exports[`#TimeProvider should render multiple children 1`] = ` 23 | 24 |
25 | Test1 26 |
27 |
28 | Test2 29 |
30 |
31 | Test3 32 |
33 |
34 | `; 35 | 36 | exports[`#TimeProvider should render null when no children are provided 1`] = ``; 37 | -------------------------------------------------------------------------------- /src/__snapshots__/timed.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`#Timed should respect prop updates 1`] = ` 4 | 5 |
6 | 0 7 |
8 |
9 | `; 10 | 11 | exports[`#Timed should respect prop updates 2`] = ` 12 | 13 |
14 | 1 15 |
16 |
17 | `; 18 | 19 | exports[`#Timed should respect prop updates 3`] = ` 20 | 21 |
22 | 2 23 |
24 |
25 | `; 26 | 27 | exports[`#Timed should respect prop updates 4`] = ` 28 | 29 |
30 | 0 31 |
32 |
33 | `; 34 | 35 | exports[`#Timed should respect prop updates 5`] = ` 36 | 37 |
38 | 300 39 |
40 |
41 | `; 42 | -------------------------------------------------------------------------------- /src/connect-time.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { connectTime } from "./connect-time"; 3 | import { createMockProvider } from "../test/mock-time-provider"; 4 | import FakeTimers from "@sinonjs/fake-timers"; 5 | import { act, render, cleanup } from "@testing-library/react"; 6 | 7 | describe("#connectTime", () => { 8 | let clock: FakeTimers.InstalledClock; 9 | 10 | beforeEach(() => { 11 | clock = FakeTimers.install({ now: 1 }); 12 | }); 13 | 14 | afterEach(() => { 15 | clock.uninstall(); 16 | cleanup(); 17 | }); 18 | 19 | it("should be exported correctly", () => { 20 | expect(connectTime).toBeInstanceOf(Function); 21 | }); 22 | 23 | it("should return wrapped component without any arguments", () => { 24 | expect(connectTime()).toBeDefined(); 25 | }); 26 | 27 | it("should throw for invalid timeProp", () => { 28 | expect(() => 29 | connectTime(null, { timeProp: 23 } as any), 30 | ).toThrowErrorMatchingSnapshot(); 31 | expect(() => 32 | connectTime(null, { timeProp: 23.6 } as any), 33 | ).toThrowErrorMatchingSnapshot(); 34 | expect(() => 35 | connectTime(null, { timeProp: [] } as any), 36 | ).toThrowErrorMatchingSnapshot(); 37 | expect(() => 38 | connectTime(null, { timeProp: ["a"] } as any), 39 | ).toThrowErrorMatchingSnapshot(); 40 | expect(() => 41 | connectTime(null, { timeProp: {} } as any), 42 | ).toThrowErrorMatchingSnapshot(); 43 | expect(() => 44 | connectTime(null, { timeProp: { b: 1 } } as any), 45 | ).toThrowErrorMatchingSnapshot(); 46 | expect(() => 47 | connectTime(null, { timeProp: "" }), 48 | ).toThrowErrorMatchingSnapshot(); 49 | }); 50 | 51 | it("should pass through timerConfig to addTimer", () => { 52 | const addTimer = jest.fn(); 53 | const MockProvider = createMockProvider({ 54 | addTimer, 55 | }); 56 | const timerConfig = { 57 | unit: 1, 58 | }; 59 | const WrappedComponent = connectTime(timerConfig)(() =>
); 60 | 61 | render( 62 | 63 | 64 | , 65 | ); 66 | 67 | expect(addTimer).toHaveBeenCalledTimes(1); 68 | expect(addTimer.mock.calls[0][1]).toBe(timerConfig); 69 | }); 70 | 71 | it("should call removeTimer on componentWillUnmount", () => { 72 | const removeTimer = jest.fn(); 73 | const addTimer = jest.fn(() => removeTimer); 74 | const MockProvider = createMockProvider({ 75 | addTimer, 76 | }); 77 | const timerConfig = { 78 | unit: 1, 79 | }; 80 | const WrappedComponent = connectTime(timerConfig)(() =>
); 81 | 82 | const { unmount } = render( 83 | 84 | 85 | , 86 | ); 87 | expect(removeTimer).toHaveBeenCalledTimes(0); 88 | unmount(); 89 | expect(removeTimer).toHaveBeenCalledTimes(1); 90 | }); 91 | 92 | it("should update children on a timer tick", () => { 93 | const WrappedComponent = connectTime()(({ currentTime }) => ( 94 |
{currentTime}
95 | )); 96 | 97 | const { asFragment } = render(); 98 | 99 | expect(asFragment()).toMatchSnapshot(); 100 | act(() => { 101 | clock.tick(999); 102 | }); 103 | expect(asFragment()).toMatchSnapshot(); 104 | }); 105 | 106 | it("should use the specified property name for timeProp", () => { 107 | const EmptyComponent = jest.fn< 108 | ReturnType, 109 | Parameters> 110 | >(() => null); 111 | const WrappedComponent = connectTime(null, { timeProp: "test1" })( 112 | EmptyComponent, 113 | ); 114 | 115 | render(); 116 | 117 | expect(EmptyComponent).toHaveBeenLastCalledWith({ test1: 0 }, {}); 118 | act(() => { 119 | clock.tick(999); 120 | }); 121 | expect(EmptyComponent).toHaveBeenLastCalledWith({ test1: 1 }, {}); 122 | }); 123 | 124 | it("should be reusable for multiple components", () => { 125 | const connect = connectTime(); 126 | const Child1 = connect(({ currentTime }) => ( 127 |
Child 1: {currentTime}
128 | )); 129 | const Child2 = connect(({ currentTime }) => ( 130 |
Child 2: {currentTime}
131 | )); 132 | const Child3 = connect(({ currentTime }) => ( 133 |
Child 3: {currentTime}
134 | )); 135 | const { asFragment } = render( 136 | <> 137 | 138 | 139 | 140 | , 141 | ); 142 | 143 | expect(asFragment()).toMatchSnapshot(); 144 | }); 145 | }); 146 | -------------------------------------------------------------------------------- /src/connect-time.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { ReactComponentLike } from "prop-types"; 3 | import { useTime, TimerConfig } from "./use-time"; 4 | 5 | interface ComponentConfig { 6 | timeProp: string; 7 | } 8 | 9 | const DEFAULT_COMPONENT_CONFIG: ComponentConfig = { 10 | timeProp: "currentTime", 11 | }; 12 | 13 | function validateComponentConfig( 14 | componentConfig: Partial, 15 | ): void { 16 | if ( 17 | typeof componentConfig.timeProp !== "undefined" && 18 | (typeof componentConfig.timeProp !== "string" || 19 | componentConfig.timeProp === "") 20 | ) { 21 | throw new Error("timeProp must be a non-empty string value"); 22 | } 23 | } 24 | 25 | export function connectTime( 26 | timerConfig?: TimerConfig | null, 27 | componentConfig: Partial = {}, 28 | ): ( 29 | WrappedComponent: ReactComponentLike, 30 | ) => (props: Record) => JSX.Element { 31 | validateComponentConfig(componentConfig); 32 | 33 | const usedTimerConfig = timerConfig || {}; 34 | const usedComponentConfig = { 35 | ...DEFAULT_COMPONENT_CONFIG, 36 | ...componentConfig, 37 | }; 38 | 39 | return ( 40 | WrappedComponent: ReactComponentLike, 41 | ): ((props: Record) => JSX.Element) => { 42 | return function TimeComponent(props: Record): JSX.Element { 43 | const timeProps = { 44 | ...props, 45 | [usedComponentConfig.timeProp]: useTime(usedTimerConfig), 46 | }; 47 | return ; 48 | }; 49 | }; 50 | } 51 | -------------------------------------------------------------------------------- /src/context.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import TimeSync from "time-sync"; 3 | 4 | export const TIMESYNC_PROP = "$$_TIME_SYNC_HIDDEN_$$"; 5 | 6 | export interface TimeSyncContext { 7 | getCurrentTime: typeof TimeSync.getCurrentTime; 8 | getTimeLeft: typeof TimeSync.getTimeLeft; 9 | addTimer: typeof TimeSync.prototype.addTimer; 10 | createCountdown: typeof TimeSync.prototype.createCountdown; 11 | } 12 | 13 | const timeSync = new TimeSync(); 14 | 15 | export default React.createContext({ 16 | getCurrentTime: TimeSync.getCurrentTime, 17 | getTimeLeft: TimeSync.getTimeLeft, 18 | addTimer: timeSync.addTimer, 19 | createCountdown: timeSync.createCountdown, 20 | }); 21 | -------------------------------------------------------------------------------- /src/countdown.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import TimeSync from "time-sync"; 3 | import Countdown from "./countdown"; 4 | import FakeTimers from "@sinonjs/fake-timers"; 5 | import { act, render, cleanup } from "@testing-library/react"; 6 | import { actTicks } from "../test/util"; 7 | 8 | describe("#Countdown", () => { 9 | let clock: FakeTimers.InstalledClock; 10 | 11 | beforeEach(() => { 12 | clock = FakeTimers.install({ now: 1 }); 13 | }); 14 | 15 | afterEach(() => { 16 | clock.uninstall(); 17 | cleanup(); 18 | }); 19 | 20 | it("should be exported correctly", () => expect(Countdown).toBeDefined()); 21 | 22 | it("should mount and unmount correctly", () => { 23 | const { unmount } = render({() =>
}); 24 | unmount(); 25 | }); 26 | 27 | it("should not break if no children are passed down", () => { 28 | render(); 29 | }); 30 | 31 | it("should not update when until number is reached", () => { 32 | clock.tick(999); 33 | let renderCalledCount = 0; 34 | const { asFragment, unmount } = render( 35 | 36 | {({ timeLeft }) => { 37 | renderCalledCount++; 38 | return
{timeLeft}
; 39 | }} 40 |
, 41 | ); 42 | expect(asFragment()).toMatchSnapshot(); 43 | actTicks(act, clock, 1000, 10); 44 | expect(asFragment()).toMatchSnapshot(); 45 | unmount(); 46 | expect(renderCalledCount).toBe(1); 47 | }); 48 | 49 | it("should stop countdown if it has ended", () => { 50 | let renderCalledCount = 0; 51 | const timeLefts: number[] = []; 52 | const { asFragment, unmount } = render( 53 | 54 | {({ timeLeft }) => { 55 | renderCalledCount++; 56 | timeLefts.push(timeLeft); 57 | return
{timeLeft}
; 58 | }} 59 |
, 60 | ); 61 | expect(asFragment()).toMatchSnapshot(); 62 | actTicks(act, clock, 1000, 6); 63 | expect(asFragment()).toMatchSnapshot(); 64 | unmount(); 65 | expect(renderCalledCount).toBe(7); 66 | expect(timeLefts).toEqual([6, 5, 4, 3, 2, 1, 0]); 67 | }); 68 | 69 | it("should update countdown if props are updated", () => { 70 | let renderCalledCount = 0; 71 | const timeLefts: number[] = []; 72 | const { rerender } = render( 73 | 74 | {({ timeLeft }) => { 75 | renderCalledCount++; 76 | timeLefts.push(timeLeft); 77 | return
{timeLeft}
; 78 | }} 79 |
, 80 | ); 81 | actTicks(act, clock, 1000, 5); 82 | expect(renderCalledCount).toBe(3); 83 | rerender( 84 | 85 | {({ timeLeft }) => { 86 | renderCalledCount++; 87 | timeLefts.push(timeLeft); 88 | return
{timeLeft}
; 89 | }} 90 |
, 91 | ); 92 | actTicks(act, clock, 1000, 20); 93 | expect(renderCalledCount).toBe(14); 94 | expect(timeLefts).toEqual([2, 1, 0, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0]); 95 | }); 96 | 97 | it("should use the interval provided as a prop", () => { 98 | let renderCalledCount = 0; 99 | const timeLefts: number[] = []; 100 | render( 101 | 102 | {({ timeLeft }) => { 103 | renderCalledCount++; 104 | timeLefts.push(timeLeft); 105 | return
{timeLeft}
; 106 | }} 107 |
, 108 | ); 109 | actTicks(act, clock, 1000 * 60 * 60, 4); 110 | expect(renderCalledCount).toBe(3); 111 | expect(timeLefts).toEqual([2, 1, 0]); 112 | }); 113 | }); 114 | -------------------------------------------------------------------------------- /src/countdown.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement, FC } from "react"; 2 | import PropTypes from "prop-types"; 3 | import { useCountdown, PartialCountdownConfig } from "./use-countdown"; 4 | 5 | export type CountdownChildrenType = (obj: { 6 | timeLeft: number; 7 | }) => ReactElement | ReactElement[]; 8 | 9 | interface CountdownConfigProps extends PartialCountdownConfig { 10 | children?: CountdownChildrenType; 11 | } 12 | 13 | const Countdown: FC = (props): ReactElement | null => { 14 | const timeLeft = useCountdown(props); 15 | 16 | if (!props.children) { 17 | return null; 18 | } 19 | 20 | return <>{props.children({ timeLeft })}; 21 | }; 22 | 23 | Countdown.propTypes = { 24 | children: PropTypes.func, 25 | }; 26 | 27 | export default Countdown; 28 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DAYS, 3 | HOURS, 4 | MINUTES, 5 | SECONDS, 6 | TimeProvider, 7 | Timed, 8 | Countdown, 9 | connectTime, 10 | useTime, 11 | useCountdown, 12 | } from "./index"; 13 | 14 | describe("#index", () => { 15 | it("should export connectTime correctly", () => { 16 | expect(connectTime).toBeInstanceOf(Function); 17 | }); 18 | 19 | it("should export TimeProvider correctly", () => { 20 | expect(TimeProvider).toBeDefined(); 21 | }); 22 | 23 | it("should export Timed correctly", () => { 24 | expect(Timed).toBeDefined(); 25 | }); 26 | 27 | it("should export Countdown correctly", () => { 28 | expect(Countdown).toBeDefined(); 29 | }); 30 | 31 | it("should export SECONDS correctly", () => { 32 | expect(SECONDS).toBeDefined(); 33 | }); 34 | 35 | it("should export MINUTES correctly", () => { 36 | expect(MINUTES).toBeDefined(); 37 | }); 38 | 39 | it("should export HOURS correctly", () => { 40 | expect(HOURS).toBeDefined(); 41 | }); 42 | 43 | it("should export DAYS correctly", () => { 44 | expect(DAYS).toBeDefined(); 45 | }); 46 | 47 | it("should export useTime correctly", () => { 48 | expect(useTime).toBeInstanceOf(Function); 49 | }); 50 | 51 | it("should export useCountdown correctly", () => { 52 | expect(useCountdown).toBeInstanceOf(Function); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import TimeSync from "time-sync"; 2 | 3 | export { connectTime } from "./connect-time"; 4 | export { default as TimeProvider } from "./time-provider"; 5 | export { default as Timed } from "./timed"; 6 | export { default as Countdown } from "./countdown"; 7 | export { useTime } from "./use-time"; 8 | export { useCountdown } from "./use-countdown"; 9 | export const { SECONDS, MINUTES, HOURS, DAYS } = TimeSync; 10 | -------------------------------------------------------------------------------- /src/time-provider.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import TimeContext from "./context"; 3 | import TimeProvider from "./time-provider"; 4 | import TimeSync from "time-sync"; 5 | import { Timers } from "time-sync/timers"; 6 | import { render, cleanup } from "@testing-library/react"; 7 | import { Countdowns } from "time-sync/countdowns"; 8 | 9 | let pageVisible: DocumentVisibilityState | undefined = "visible"; 10 | 11 | beforeAll(() => { 12 | Object.defineProperty(document, "visibilityState", { 13 | configurable: true, 14 | get: function () { 15 | return pageVisible; 16 | }, 17 | }); 18 | }); 19 | 20 | beforeEach(() => { 21 | pageVisible = "visible"; 22 | }); 23 | 24 | describe("#TimeProvider", () => { 25 | afterEach(() => { 26 | cleanup(); 27 | jest.clearAllMocks(); 28 | }); 29 | 30 | it("should be exported correctly", () => { 31 | expect(TimeProvider).toBeDefined(); 32 | }); 33 | 34 | it("should mount and unmount correctly", () => { 35 | const { unmount } = render(); 36 | unmount(); 37 | }); 38 | 39 | it("should render null when no children are provided", () => { 40 | const { asFragment } = render(); 41 | expect(asFragment()).toMatchSnapshot(); 42 | }); 43 | 44 | it("should render a single child", () => { 45 | const { asFragment } = render( 46 | 47 |
Test
48 |
, 49 | ); 50 | expect(asFragment()).toMatchSnapshot(); 51 | }); 52 | 53 | it("should render multiple children", () => { 54 | const { asFragment } = render( 55 | 56 |
Test1
57 |
Test2
58 |
Test3
59 |
, 60 | ); 61 | expect(asFragment()).toMatchSnapshot(); 62 | }); 63 | 64 | it("should provide timeSync context", () => { 65 | const Child: any = jest.fn(() => null); 66 | render( 67 | 68 | 69 | {(timeSync) => } 70 | 71 | , 72 | ); 73 | 74 | expect(Child.mock.calls[0][0].timeSync).toBeDefined(); 75 | }); 76 | 77 | it("should provide the getCurrentTime function in the timeSync context", () => { 78 | const Child: any = jest.fn(() => null); 79 | render( 80 | 81 | 82 | {(timeSync) => } 83 | 84 | , 85 | ); 86 | 87 | expect(Child.mock.calls[0][0].timeSync.getCurrentTime).toBeInstanceOf( 88 | Function, 89 | ); 90 | }); 91 | 92 | it("should provide the addTimer function in the timeSync context", () => { 93 | const Child: any = jest.fn(() => null); 94 | render( 95 | 96 | 97 | {(timeSync) => } 98 | 99 | , 100 | ); 101 | 102 | expect(Child.mock.calls[0][0].timeSync.getCurrentTime).toBeInstanceOf( 103 | Function, 104 | ); 105 | }); 106 | 107 | it("should call removeAllTimers when unmounting", () => { 108 | const removeAllTimers = jest.spyOn(Timers.prototype, "removeAllTimers"); 109 | 110 | const { unmount } = render(); 111 | unmount(); 112 | 113 | expect(removeAllTimers).toHaveBeenCalledTimes(1); 114 | }); 115 | 116 | it("should call stopAllCountdowns when unmounting", () => { 117 | const stopAllCountdowns = jest.spyOn( 118 | Countdowns.prototype, 119 | "stopAllCountdowns", 120 | ); 121 | 122 | const { unmount } = render(); 123 | unmount(); 124 | 125 | expect(stopAllCountdowns).toHaveBeenCalledTimes(1); 126 | }); 127 | 128 | it("should use provided timeSync instance if possible", () => { 129 | const addTimer = jest.fn(); 130 | const removeAllTimers = jest.spyOn(Timers.prototype, "removeAllTimers"); 131 | const stopAllCountdowns = jest.spyOn( 132 | Countdowns.prototype, 133 | "stopAllCountdowns", 134 | ); 135 | 136 | class TimeSync { 137 | public removeAllTimers = jest.fn(); 138 | public stopAllCountdowns = jest.fn(); 139 | public addTimer = addTimer; 140 | public revalidate = jest.fn(); 141 | public createCountdown = jest.fn(); 142 | public getCurrentTime = jest.fn(); 143 | public getTimeLeft = jest.fn(); 144 | } 145 | 146 | function ContextConsumer(): JSX.Element { 147 | return ( 148 | 149 | {(timeSync) => { 150 | timeSync.addTimer(() => { 151 | // no-op 152 | }); 153 | return null; 154 | }} 155 | 156 | ); 157 | } 158 | 159 | const timeSync = new TimeSync(); 160 | const { unmount } = render( 161 | 162 | 163 | , 164 | ); 165 | unmount(); 166 | 167 | expect(addTimer).toHaveBeenCalledTimes(1); 168 | 169 | // Timers & countdowns should not be stopped for 170 | // custom provided TimeSync instances 171 | expect(stopAllCountdowns).not.toHaveBeenCalled(); 172 | expect(removeAllTimers).not.toHaveBeenCalled(); 173 | }); 174 | 175 | it("should accept default ReactProps.children type as children", () => { 176 | const timeSync = new TimeSync(); 177 | 178 | const ExampleWrapper: React.FC> = ({ 179 | children, 180 | }) => { 181 | return {children}; 182 | }; 183 | 184 | const { asFragment } = render( 185 | 186 |
Test1
187 |
Test2
188 |
, 189 | ); 190 | expect(asFragment()).toMatchSnapshot(); 191 | }); 192 | 193 | describe("#page visibility change", () => { 194 | it("should revalidate when page becomes visible again", () => { 195 | const revalidateAllTimers = jest.spyOn( 196 | Timers.prototype, 197 | "revalidateAllTimers", 198 | ); 199 | const revalidateAllCountdowns = jest.spyOn( 200 | Countdowns.prototype, 201 | "revalidateAllCountdowns", 202 | ); 203 | const Wrapper: React.FC = () => { 204 | return ; 205 | }; 206 | 207 | const { unmount } = render(); 208 | 209 | pageVisible = "hidden"; 210 | document.dispatchEvent(new Event("visibilitychange")); 211 | 212 | expect(revalidateAllCountdowns).toHaveBeenCalledTimes(0); 213 | expect(revalidateAllTimers).toHaveBeenCalledTimes(0); 214 | 215 | pageVisible = "visible"; 216 | document.dispatchEvent(new Event("visibilitychange")); 217 | 218 | expect(revalidateAllCountdowns).toHaveBeenCalledTimes(1); 219 | expect(revalidateAllTimers).toHaveBeenCalledTimes(1); 220 | 221 | unmount(); 222 | }); 223 | 224 | it("should not revalidate when visibilityState is not supported", () => { 225 | const revalidateAllTimers = jest.spyOn( 226 | Timers.prototype, 227 | "revalidateAllTimers", 228 | ); 229 | const revalidateAllCountdowns = jest.spyOn( 230 | Countdowns.prototype, 231 | "revalidateAllCountdowns", 232 | ); 233 | const Wrapper: React.FC = () => { 234 | return ; 235 | }; 236 | 237 | pageVisible = undefined; 238 | 239 | const { unmount } = render(); 240 | 241 | pageVisible = "hidden"; 242 | document.dispatchEvent(new Event("visibilitychange")); 243 | 244 | expect(revalidateAllCountdowns).toHaveBeenCalledTimes(0); 245 | expect(revalidateAllTimers).toHaveBeenCalledTimes(0); 246 | 247 | pageVisible = "visible"; 248 | document.dispatchEvent(new Event("visibilitychange")); 249 | 250 | expect(revalidateAllCountdowns).toHaveBeenCalledTimes(0); 251 | expect(revalidateAllTimers).toHaveBeenCalledTimes(0); 252 | 253 | unmount(); 254 | }); 255 | }); 256 | }); 257 | -------------------------------------------------------------------------------- /src/time-provider.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode, useEffect, useMemo, useRef } from "react"; 2 | import PropTypes from "prop-types"; 3 | import TimeContext from "./context"; 4 | import TimeSync from "time-sync"; 5 | 6 | interface TimeProviderProps { 7 | timeSync?: TimeSync; 8 | } 9 | 10 | const TimeProvider: React.FC> = ( 11 | props, 12 | ) => { 13 | const timeSyncFallback = useRef(null); 14 | if (!props.timeSync && !timeSyncFallback.current) { 15 | timeSyncFallback.current = new TimeSync(); 16 | } 17 | const timeSync = props.timeSync || (timeSyncFallback.current as TimeSync); 18 | 19 | const timeContext = useMemo( 20 | () => ({ 21 | getCurrentTime: TimeSync.getCurrentTime, 22 | getTimeLeft: TimeSync.getTimeLeft, 23 | addTimer: timeSync.addTimer, 24 | createCountdown: timeSync.createCountdown, 25 | }), 26 | [timeSync], 27 | ); 28 | 29 | useEffect(() => { 30 | return () => { 31 | if (!props.timeSync) { 32 | timeSync.removeAllTimers(); 33 | timeSync.stopAllCountdowns(); 34 | } 35 | }; 36 | }, [props.timeSync, timeSync]); 37 | 38 | useEffect(() => { 39 | function handleVisibilityChange() { 40 | if (document.visibilityState === "visible") { 41 | timeSync.revalidate(); 42 | } 43 | } 44 | 45 | let listenerSet = false; 46 | if ( 47 | typeof document !== "undefined" && 48 | typeof document.visibilityState !== "undefined" 49 | ) { 50 | document.addEventListener("visibilitychange", handleVisibilityChange); 51 | listenerSet = true; 52 | } 53 | return () => { 54 | if (listenerSet) { 55 | document.removeEventListener( 56 | "visibilitychange", 57 | handleVisibilityChange, 58 | ); 59 | } 60 | }; 61 | }, [timeSync]); 62 | 63 | return ( 64 | 65 | {props.children} 66 | 67 | ); 68 | }; 69 | 70 | TimeProvider.propTypes = { 71 | children: PropTypes.node as unknown as React.Validator, 72 | timeSync: PropTypes.object as unknown as React.Validator< 73 | TimeSync | undefined 74 | >, 75 | }; 76 | 77 | export default TimeProvider; 78 | -------------------------------------------------------------------------------- /src/timed.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Timed from "./timed"; 3 | import TimeSync from "time-sync"; 4 | import FakeTimers from "@sinonjs/fake-timers"; 5 | import { render, cleanup, act } from "@testing-library/react"; 6 | import { actTicks } from "../test/util"; 7 | 8 | describe("#Timed", () => { 9 | let clock: FakeTimers.InstalledClock; 10 | 11 | beforeEach(() => { 12 | clock = FakeTimers.install({ now: 1 }); 13 | }); 14 | 15 | afterEach(() => { 16 | clock.uninstall(); 17 | cleanup(); 18 | }); 19 | 20 | it("should be exported correctly", () => expect(Timed).toBeDefined()); 21 | 22 | it("should mount and unmount correctly", () => { 23 | const { unmount } = render({() =>
}); 24 | unmount(); 25 | }); 26 | 27 | it("should not break if no children are passed down", () => { 28 | render(); 29 | }); 30 | 31 | it("should respect prop updates", () => { 32 | let renderCalledCount = 0; 33 | 34 | const { asFragment, rerender, unmount } = render( 35 | 36 | {({ currentTime }: { currentTime: number }) => { 37 | renderCalledCount++; 38 | return
{currentTime}
; 39 | }} 40 |
, 41 | ); 42 | 43 | expect(asFragment()).toMatchSnapshot(); 44 | 45 | act(() => { 46 | clock.tick(999); 47 | }); 48 | expect(asFragment()).toMatchSnapshot(); 49 | 50 | act(() => { 51 | clock.tick(1000); 52 | }); 53 | expect(asFragment()).toMatchSnapshot(); 54 | 55 | const newProps = { 56 | unit: 5, 57 | interval: TimeSync.MINUTES, 58 | }; 59 | 60 | rerender( 61 | 62 | {({ currentTime }: { currentTime: number }) => { 63 | renderCalledCount++; 64 | return
{currentTime}
; 65 | }} 66 |
, 67 | ); 68 | 69 | expect(asFragment()).toMatchSnapshot(); 70 | 71 | actTicks(act, clock, 1000 * 60, 5); 72 | 73 | expect(asFragment()).toMatchSnapshot(); 74 | 75 | unmount(); 76 | 77 | expect(renderCalledCount).toBe(5); 78 | }); 79 | }); 80 | -------------------------------------------------------------------------------- /src/timed.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, ReactElement } from "react"; 2 | import PropTypes from "prop-types"; 3 | import { useTime, TimerConfig } from "./use-time"; 4 | 5 | export type TimedChildrenType = (obj: { 6 | currentTime: number; 7 | }) => ReactElement | ReactElement[]; 8 | 9 | interface TimerConfigProps extends TimerConfig { 10 | children?: TimedChildrenType; 11 | } 12 | 13 | const Timed: FC = (props): ReactElement | null => { 14 | const currentTime = useTime(props); 15 | 16 | if (!props.children) { 17 | return null; 18 | } 19 | 20 | return <>{props.children({ currentTime })}; 21 | }; 22 | 23 | Timed.propTypes = { 24 | children: PropTypes.func, 25 | }; 26 | 27 | export default Timed; 28 | -------------------------------------------------------------------------------- /src/use-countdown.test.tsx: -------------------------------------------------------------------------------- 1 | import TimeSync from "time-sync"; 2 | import { useCountdown, PartialCountdownConfig } from "./use-countdown"; 3 | import FakeTimers from "@sinonjs/fake-timers"; 4 | import { act, renderHook } from "@testing-library/react-hooks"; 5 | import { actTicks } from "../test/util"; 6 | 7 | describe("#Countdown", () => { 8 | let clock: FakeTimers.InstalledClock; 9 | 10 | beforeEach(() => { 11 | clock = FakeTimers.install({ now: 1 }); 12 | }); 13 | 14 | afterEach(() => { 15 | clock.uninstall(); 16 | }); 17 | 18 | it("should be exported correctly", () => expect(useCountdown).toBeDefined()); 19 | 20 | it("should not update when until number is reached", () => { 21 | clock.tick(999); 22 | let renderCalledCount = 0; 23 | 24 | const { result, unmount } = renderHook(() => { 25 | renderCalledCount++; 26 | return useCountdown({ until: 1 }); 27 | }); 28 | 29 | expect(result.error).toBeUndefined(); 30 | expect(result.current).toBe(0); 31 | 32 | actTicks(act, clock, 1000, 10); 33 | 34 | expect(result.error).toBeUndefined(); 35 | expect(result.current).toBe(0); 36 | 37 | act(() => { 38 | unmount(); 39 | }); 40 | 41 | expect(renderCalledCount).toBe(1); 42 | }); 43 | 44 | it("should stop countdown if it has ended", () => { 45 | let renderCalledCount = 0; 46 | const timeLefts: number[] = []; 47 | 48 | const { result, unmount } = renderHook(() => { 49 | renderCalledCount++; 50 | const timeLeft = useCountdown({ until: 5002 }); 51 | timeLefts.push(timeLeft); 52 | return timeLeft; 53 | }); 54 | 55 | expect(result.error).toBeUndefined(); 56 | expect(result.current).toBe(6); 57 | 58 | actTicks(act, clock, 1000, 6); 59 | 60 | expect(result.error).toBeUndefined(); 61 | expect(result.current).toBe(0); 62 | 63 | act(() => { 64 | unmount(); 65 | }); 66 | 67 | expect(renderCalledCount).toBe(7); 68 | expect(timeLefts).toEqual([6, 5, 4, 3, 2, 1, 0]); 69 | }); 70 | 71 | it("should update countdown if props are updated", () => { 72 | let renderCalledCount = 0; 73 | const timeLefts: number[] = []; 74 | 75 | const countdownConfig: PartialCountdownConfig = {}; 76 | const { rerender } = renderHook(() => { 77 | renderCalledCount++; 78 | const timeLeft = useCountdown({ until: countdownConfig.until || 2001 }); 79 | timeLefts.push(timeLeft); 80 | return timeLeft; 81 | }); 82 | 83 | actTicks(act, clock, 1000, 5); 84 | 85 | expect(renderCalledCount).toBe(3); 86 | countdownConfig.until = 15000; 87 | rerender(); 88 | actTicks(act, clock, 1000, 20); 89 | expect(renderCalledCount).toBe(14); 90 | expect(timeLefts).toEqual([2, 1, 0, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0]); 91 | }); 92 | 93 | it("should use the interval provided as a prop", () => { 94 | let renderCalledCount = 0; 95 | const timeLefts: number[] = []; 96 | 97 | renderHook(() => { 98 | renderCalledCount++; 99 | const timeLeft = useCountdown({ 100 | until: 1000 * 60 * 60 * 2, 101 | interval: TimeSync.HOURS, 102 | }); 103 | timeLefts.push(timeLeft); 104 | return timeLeft; 105 | }); 106 | actTicks(act, clock, 1000 * 60 * 60, 5); 107 | expect(renderCalledCount).toBe(3); 108 | expect(timeLefts).toEqual([2, 1, 0]); 109 | }); 110 | 111 | it("should not start a countdown if no until value is specified", () => { 112 | let renderCalledCount = 0; 113 | const timeLefts: number[] = []; 114 | 115 | renderHook(() => { 116 | renderCalledCount++; 117 | const timeLeft = useCountdown(); 118 | timeLefts.push(timeLeft); 119 | return timeLeft; 120 | }); 121 | 122 | actTicks(act, clock, 1000, 10); 123 | 124 | expect(renderCalledCount).toBe(1); 125 | expect(timeLefts).toEqual([0]); 126 | }); 127 | }); 128 | -------------------------------------------------------------------------------- /src/use-countdown.ts: -------------------------------------------------------------------------------- 1 | import { useContext, useEffect, useDebugValue } from "react"; 2 | import TimeContext from "./context"; 3 | import { Interval } from "time-sync/constants"; 4 | import { useStateWithDeps } from "use-state-with-deps"; 5 | 6 | export interface PartialCountdownConfig { 7 | until?: number; 8 | interval?: Interval; 9 | } 10 | 11 | export interface CountdownConfig extends PartialCountdownConfig { 12 | until: number; 13 | } 14 | 15 | function getUsableConfig( 16 | countdownConfig: PartialCountdownConfig, 17 | ): CountdownConfig { 18 | return { 19 | ...countdownConfig, 20 | until: countdownConfig.until || 0, 21 | }; 22 | } 23 | 24 | export function useCountdown( 25 | countdownConfig: PartialCountdownConfig = {}, 26 | ): number { 27 | const timeSync = useContext(TimeContext); 28 | const usableConfig = getUsableConfig(countdownConfig); 29 | 30 | const [timeLeft, setTimeLeft] = useStateWithDeps(() => { 31 | return timeSync.getTimeLeft({ 32 | interval: usableConfig.interval, 33 | until: usableConfig.until, 34 | }); 35 | }, [usableConfig.interval, usableConfig.until]); 36 | 37 | useEffect((): (() => void) | void => { 38 | if (Date.now() < usableConfig.until) { 39 | return timeSync.createCountdown((newTimeLeft): void => { 40 | setTimeLeft(newTimeLeft); 41 | }, usableConfig); 42 | } 43 | }, [usableConfig.until, usableConfig.interval]); 44 | 45 | useDebugValue(timeLeft); 46 | return timeLeft; 47 | } 48 | -------------------------------------------------------------------------------- /src/use-time.test.tsx: -------------------------------------------------------------------------------- 1 | import { useTime, TimerConfig } from "./use-time"; 2 | import TimeSync from "time-sync"; 3 | import FakeTimers from "@sinonjs/fake-timers"; 4 | import { act, renderHook } from "@testing-library/react-hooks"; 5 | 6 | describe("#useTime", () => { 7 | let clock: FakeTimers.InstalledClock; 8 | 9 | beforeEach(() => { 10 | clock = FakeTimers.install({ now: 1 }); 11 | }); 12 | 13 | afterEach(() => { 14 | clock.uninstall(); 15 | }); 16 | 17 | it("should be exported correctly", () => expect(useTime).toBeDefined()); 18 | 19 | it("should use seconds if no timeConfig is provided", () => { 20 | const { result, unmount } = renderHook(() => useTime()); 21 | expect(result.current).toBe(0); 22 | act(() => { 23 | clock.tick(5000); 24 | }); 25 | expect(result.current).toBe(5); 26 | unmount(); 27 | }); 28 | 29 | it("should respect prop updates", () => { 30 | let renderCalledCount = 0; 31 | 32 | const timerConfig: TimerConfig = {}; 33 | const { result, rerender, unmount } = renderHook(() => { 34 | renderCalledCount++; 35 | return useTime({ ...timerConfig }); 36 | }); 37 | 38 | expect(result.error).toBeUndefined(); 39 | expect(result.current).toBe(0); 40 | 41 | act(() => { 42 | clock.tick(999); 43 | }); 44 | 45 | expect(result.error).toBeUndefined(); 46 | expect(result.current).toBe(1); 47 | 48 | act(() => { 49 | clock.tick(1000); 50 | }); 51 | 52 | expect(result.error).toBeUndefined(); 53 | expect(result.current).toBe(2); 54 | 55 | timerConfig.unit = 5; 56 | timerConfig.interval = TimeSync.MINUTES; 57 | 58 | rerender(); 59 | 60 | expect(result.error).toBeUndefined(); 61 | expect(result.current).toBe(0); 62 | 63 | act(() => { 64 | clock.tick(1000 * 5 * 60); 65 | }); 66 | 67 | expect(result.error).toBeUndefined(); 68 | expect(result.current).toBe(300); 69 | 70 | unmount(); 71 | 72 | expect(renderCalledCount).toBe(5); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /src/use-time.ts: -------------------------------------------------------------------------------- 1 | import { useContext, useDebugValue, useEffect } from "react"; 2 | import TimeContext from "./context"; 3 | import { Interval } from "time-sync/constants"; 4 | import { useStateWithDeps } from "use-state-with-deps"; 5 | 6 | export interface TimerConfig { 7 | interval?: Interval; 8 | unit?: number; 9 | } 10 | 11 | export function useTime(timerConfig: TimerConfig = {}): number { 12 | const timeSync = useContext(TimeContext); 13 | const [time, setTime] = useStateWithDeps( 14 | () => 15 | timeSync.getCurrentTime({ 16 | interval: timerConfig.interval, 17 | unit: timerConfig.unit, 18 | }), 19 | [timerConfig.interval, timerConfig.unit], 20 | ); 21 | 22 | useEffect( 23 | (): (() => void) => 24 | timeSync.addTimer((currentTime): void => { 25 | setTime(currentTime); 26 | }, timerConfig), 27 | [timerConfig.unit, timerConfig.interval], 28 | ); 29 | 30 | useDebugValue(time); 31 | return time; 32 | } 33 | -------------------------------------------------------------------------------- /test/mock-time-provider.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import TimeContext from "../src/context"; 3 | import PropTypes from "prop-types"; 4 | 5 | interface ProviderConfig { 6 | addTimer?: any; 7 | getCurrentTime?: any; 8 | getTimeLeft?: any; 9 | createCountdown?: any; 10 | } 11 | 12 | const DEFAULT_CONFIG: { 13 | addTimer: any; 14 | getCurrentTime: any; 15 | getTimeLeft: any; 16 | createCountdown: any; 17 | } = { 18 | addTimer: () => null, 19 | getCurrentTime: () => null, 20 | getTimeLeft: () => 0, 21 | createCountdown: () => null, 22 | }; 23 | 24 | export function createMockProvider(config: ProviderConfig): any { 25 | const mockConfig = { 26 | ...DEFAULT_CONFIG, 27 | ...config, 28 | }; 29 | 30 | class MockTimeProvider extends Component> { 31 | public static propTypes = { 32 | children: PropTypes.node, 33 | }; 34 | 35 | public static defaultProps = { 36 | children: null, 37 | }; 38 | 39 | public state = { 40 | timeSync: { 41 | ...mockConfig, 42 | }, 43 | }; 44 | 45 | public render(): JSX.Element { 46 | const { timeSync } = this.state; 47 | const { children } = this.props; 48 | 49 | return ( 50 | {children} 51 | ); 52 | } 53 | } 54 | 55 | MockTimeProvider.propTypes = { 56 | children: PropTypes.node, 57 | }; 58 | 59 | return MockTimeProvider; 60 | } 61 | -------------------------------------------------------------------------------- /test/util.ts: -------------------------------------------------------------------------------- 1 | import FakeTimers from "@sinonjs/fake-timers"; 2 | 3 | export function actTicks( 4 | act: (callback: () => void) => void, 5 | clock: FakeTimers.Clock, 6 | interval: number, 7 | count: number, 8 | ): void { 9 | for (let i = 0; i < count * 2; i++) { 10 | act((): void => { 11 | clock.tick(interval / 2); 12 | }); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "noEmit": false, 5 | "isolatedModules": false 6 | }, 7 | "exclude": ["**/**/*.test.ts", "**/**/*.test.tsx"] 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "moduleResolution": "node", 5 | "module": "commonjs", 6 | "strict": true, 7 | "lib": ["dom", "es5", "scripthost", "es2015"], 8 | "esModuleInterop": true, 9 | "jsx": "react", 10 | "outDir": "build", 11 | "skipLibCheck": true, 12 | "noEmit": true, 13 | "isolatedModules": true, 14 | "allowSyntheticDefaultImports": true, 15 | "noUnusedLocals": true, 16 | "noUnusedParameters": true 17 | }, 18 | "include": ["src"] 19 | } 20 | --------------------------------------------------------------------------------