├── .editorconfig ├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE.md ├── PULL_REQUEST_TEMPLATE.md └── labels.json ├── .gitignore ├── .prettierignore ├── .prettierrc ├── LICENSE ├── README.md ├── package.json ├── src ├── consumer.tsx ├── context.ts ├── helpers.ts ├── index.ts ├── provider.tsx └── tsconfig.json └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | # Note: prettier inherits from `indent_style`, `indent_size`/`tab_width`, and `max_line_length` 2 | # https://github.com/prettier/prettier/blob/cecf0657a521fa265b713274ed67ca39be4142cf/docs/api.md#prettierresolveconfigfilepath--options 3 | 4 | root = true 5 | 6 | [*] 7 | indent_style = space 8 | indent_size = 2 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [.vscode/settings.json] 13 | indent_size = 4 14 | 15 | # Git is sensitive to whitespace in diff files 16 | # https://stackoverflow.com/questions/50258565/git-editing-hunks-fails-when-file-has-other-hunks/50275053#50275053 17 | [*.diff] 18 | trim_trailing_whitespace = false 19 | 20 | [*.{ts,tsx}] 21 | # https://github.com/editorconfig/editorconfig/wiki/EditorConfig-Properties#max_line_length 22 | max_line_length = 100 23 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # These users will be the default owners for everything in the repo. 2 | # See https://github.com/blog/2392-introducing-code-owners 3 | 4 | * @unsplash/web 5 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | #### Steps to Reproduce 2 | 3 | - 4 | 5 | #### Observed Behaviour 6 | 7 | - 8 | 9 | > _Image or video please._ 10 | 11 | #### Expected Behaviour 12 | 13 | - 14 | 15 | #### Technical Notes 16 | 17 | - 18 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | #### Overview 2 | 3 | - 4 | 5 | - Closes # 6 | 7 | #### Todo 8 | 9 | - 10 | -------------------------------------------------------------------------------- /.github/labels.json: -------------------------------------------------------------------------------- 1 | [ 2 | { "name": "Priority: Critical", "color": "#e11d21" }, 3 | 4 | { "name": "Status: Blocked", "color": "#e11d21" }, 5 | { "name": "Status: On Hold", "color": "#e11d21" }, 6 | { "name": "Status: In Progress", "color": "#fbca04" }, 7 | { "name": "Status: Ready for Review", "color": "#bfe5bf" }, 8 | 9 | { "name": "Type: Bug", "color": "#e11d21" }, 10 | { "name": "Type: Maintenance", "color": "#fbca04" }, 11 | { "name": "Type: Enhancement", "color": "#84b6eb" } 12 | ] 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # OS X 2 | .DS_Store 3 | 4 | # Dependency directory 5 | # https://docs.npmjs.com/misc/faq#should-i-check-my-node-modules-folder-into-git 6 | node_modules 7 | 8 | # Yarn 9 | yarn-error.log 10 | 11 | # typescript dist 12 | dist/ 13 | 14 | # VSCode 15 | .vscode/ 16 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | tests/ReactSixteenAdapter.js 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "singleQuote": true 4 | } 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Unsplash 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-progressive-enhancement 2 | _A handy collection of HOCs for universally rendered apps 🤩_ 3 | 4 | npm 5 | [![Build Status](https://travis-ci.com/unsplash/react-progressive-enhancement.svg?branch=master)](https://travis-ci.com/unsplash/react-progressive-enhancement) 6 | 7 | TL;DR In universally rendered React apps, it is common to branch data-fetching and component-rendering depending on the environment (server or client), and defer rendering (a.k.a. "progressively enhance") some components. However, we must ensure the first client render matches the server render. 8 | 9 | This module achieves all the above by tracking whether or not the render mode is "enhanced" with an `isEnhanced` boolean (`true` only after first client render, otherwise `false`), which is accessed through a `withIsEnhanced` HOC. Additionally a `progressivelyEnhanced` HOC is provided which only renders the composed component for enhanced renders. 10 | 11 | For more info, check out [this blog post](https://medium.com/unsplash/react-progressive-enhancement-a-handy-collection-of-hocs-for-universally-rendered-apps-904f689768cf). 12 | 13 | ## Features 14 | 15 | * No dependencies (other than React ^16.3) 16 | * Written in TypeScript (type-annotated) 17 | * Easily extensible through the exported React Context's `Consumer` & `Provider` 18 | 19 | ## Install 20 | 21 | ```bash 22 | yarn add react-progressive-enhancement 23 | # OR 24 | npm install react-progressive-enhancement 25 | ``` 26 | 27 | ## Usage 28 | 29 | * Root.jsx: 30 | 31 | ```jsx 32 | import { enableProgressiveEnhancementsOnMount } from 'react-progressive-enhancement'; 33 | 34 | const Root = () => ( 35 |
36 | 37 |
38 | ); 39 | 40 | export default enableProgressiveEnhancementsOnMount(Root); 41 | ``` 42 | 43 | 44 | * PhotoRoute.jsx: 45 | 46 | ```jsx 47 | import { withIsEnhanced, progressivelyEnhance } from 'react-progressive-enhancement'; 48 | 49 | const ProgressivelyEnhancedRelatedContent = progressivelyEnhance(RelatedContent); 50 | 51 | class PhotoRoute extends React.Component { 52 | componentDidMount() { 53 | const hasDataFromServer = !this.props.isEnhanced; 54 | 55 | if (!hasDataFromServer) { 56 | this.getPhotoRouteData(); 57 | } else { 58 | // do nothing, because the server already fetched the data and passed it to the client. 59 | } 60 | } 61 | 62 | render() { 63 | return ( 64 |
65 | 66 | {/* This component will only render after the first client render */} 67 | 68 |
69 | ); 70 | } 71 | } 72 | 73 | export default withIsEnhanced(PhotoRoute); 74 | ``` 75 | 76 | ## API Reference 77 | 78 | #### enableProgressiveEnhancementsOnMount 79 | ```tsx 80 | (ComposedComponent: React.Component) => React.Component 81 | ``` 82 | 83 | An HOC that wraps `ComposedComponent` with the Context Provider. `ComposedComponent` should be the root-most Component in your React app. 84 | 85 | #### withIsEnhanced 86 | ```tsx 87 | (ComposedComponent: React.Component) => React.Component 88 | ``` 89 | 90 | An HOC that provides the `isEnhanced` prop to `ComposedComponent`. 91 | 92 | #### progressivelyEnhance 93 | ```tsx 94 | (ComposedComponent: React.Component) => React.Component 95 | ``` 96 | 97 | An HOC that defers rendering `ComposedComponent` until after the first client render. 98 | 99 | #### Consumer, Provider 100 | ```tsx 101 | React.Context 102 | ``` 103 | 104 | The Context's `Consumer` and `Provider` are exported as well, so that you can easily extend this library as you see fit. 105 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-progressive-enhancement", 3 | "version": "0.0.10", 4 | "description": "React Context that progressively enhances components", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "files": [ 8 | "dist/", 9 | "yarn.lock" 10 | ], 11 | "dependencies": { 12 | "@types/react": "^18.0.8" 13 | }, 14 | "devDependencies": { 15 | "@types/react-dom": "^18.0.0", 16 | "react": "^18.1.0", 17 | "react-dom": "^18.1.0", 18 | "ts-node": "^6.1.0", 19 | "typescript": "^4.6.3" 20 | }, 21 | "peerDependencies": { 22 | "react": "^17.0.1 || ^18.0.0" 23 | }, 24 | "scripts": { 25 | "compile": "tsc --project src/tsconfig.json", 26 | "prepublishOnly": "npm run compile" 27 | }, 28 | "repository": { 29 | "type": "git", 30 | "url": "git+https://github.com/unsplash/react-progressive-enhancement.git" 31 | }, 32 | "contributors": [ 33 | { 34 | "name": "Sami Jaber", 35 | "email": "jabersami@gmail.com" 36 | } 37 | ], 38 | "engines": { 39 | "yarn": "^1.5.1" 40 | }, 41 | "license": "MIT", 42 | "bugs": { 43 | "url": "https://github.com/unsplash/react-progressive-enhancement/issues" 44 | }, 45 | "homepage": "https://github.com/unsplash/react-progressive-enhancement#readme" 46 | } 47 | -------------------------------------------------------------------------------- /src/consumer.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { context, ProgressiveEnhancementProp } from './context'; 4 | import { ObjectOmit, getDisplayName } from './helpers'; 5 | 6 | const { Consumer } = context; 7 | 8 | export const withIsEnhanced = ( 9 | ComposedComponent: React.ComponentType, 10 | ) => { 11 | type ComponentWithIsEnhancedType = React.FC>; 12 | const ComponentWithIsEnhanced: ComponentWithIsEnhancedType = (props) => ( 13 | 14 | {({ isEnhanced }) => ( 15 | 24 | )} 25 | 26 | ); 27 | 28 | ComponentWithIsEnhanced.displayName = `withIsEnhanced(${getDisplayName(ComposedComponent)})`; 29 | 30 | return ComponentWithIsEnhanced; 31 | }; 32 | 33 | export const progressivelyEnhance = ( 34 | ComposedComponent: React.ComponentType, 35 | ) => { 36 | const ProgressivelyEnhance: React.FC = (props) => ( 37 | 38 | {({ isEnhanced }) => (isEnhanced ? : null)} 39 | 40 | ); 41 | 42 | ProgressivelyEnhance.displayName = `ProgressivelyEnhance(${getDisplayName(ComposedComponent)})`; 43 | 44 | return ProgressivelyEnhance; 45 | }; 46 | -------------------------------------------------------------------------------- /src/context.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | export type ProgressiveEnhancementProp = { 4 | isEnhanced: boolean; 5 | }; 6 | 7 | export const defaultValue: ProgressiveEnhancementProp = { 8 | isEnhanced: false, 9 | }; 10 | 11 | export const context = React.createContext(defaultValue); 12 | 13 | context.displayName = 'ProgressiveEnhancementContext'; 14 | -------------------------------------------------------------------------------- /src/helpers.ts: -------------------------------------------------------------------------------- 1 | import { ComponentType } from 'react'; 2 | 3 | export const getDisplayName = (ComposedComponent: ComponentType) => 4 | ComposedComponent.displayName !== undefined ? ComposedComponent.displayName : 'Component'; 5 | 6 | export type Omit = Pick>; 7 | export type ObjectOmit = Omit; 8 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { progressivelyEnhance, withIsEnhanced } from './consumer'; 2 | export { enableProgressiveEnhancementsOnMount } from './provider'; 3 | export { context, ProgressiveEnhancementProp } from './context'; 4 | -------------------------------------------------------------------------------- /src/provider.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { ProgressiveEnhancementProp, context, defaultValue } from './context'; 4 | import { getDisplayName } from './helpers'; 5 | 6 | const { Provider } = context; 7 | 8 | export const enableProgressiveEnhancementsOnMount = ( 9 | ComposedComponent: React.ComponentType, 10 | ): React.ComponentType => { 11 | class ProgressiveEnhancementProvider extends React.Component { 12 | static displayName = `ProgressiveEnhancementProvider(${getDisplayName(ComposedComponent)})`; 13 | 14 | state = defaultValue; 15 | 16 | componentDidMount() { 17 | this.setState({ isEnhanced: true }); 18 | } 19 | 20 | render() { 21 | return ( 22 | 23 | 24 | 25 | ); 26 | } 27 | } 28 | 29 | return ProgressiveEnhancementProvider; 30 | }; 31 | -------------------------------------------------------------------------------- /src/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es5", 5 | "lib": [ 6 | // start defaults 7 | "dom", 8 | // end defaults 9 | "es2015" 10 | ], 11 | "jsx": "react", 12 | "declaration": true, 13 | "outDir": "../dist", 14 | "strict": true 15 | }, 16 | "files": ["./index.ts"] 17 | } 18 | -------------------------------------------------------------------------------- /yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | "@types/prop-types@*": 6 | version "15.7.1" 7 | resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.1.tgz#f1a11e7babb0c3cad68100be381d1e064c68f1f6" 8 | integrity sha512-CFzn9idOEpHrgdw8JsoTkaDDyRWk1jrzIV8djzcgpq0y9tG4B4lFT+Nxh52DVpDXV+n4+NPNv7M1Dj5uMp6XFg== 9 | 10 | "@types/react-dom@^18.0.0": 11 | version "18.0.0" 12 | resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.0.0.tgz#b13f8d098e4b0c45df4f1ed123833143b0c71141" 13 | integrity sha512-49897Y0UiCGmxZqpC8Blrf6meL8QUla6eb+BBhn69dTXlmuOlzkfr7HHY/O8J25e1lTUMs+YYxSlVDAaGHCOLg== 14 | dependencies: 15 | "@types/react" "*" 16 | 17 | "@types/react@*", "@types/react@^18.0.8": 18 | version "18.0.8" 19 | resolved "https://registry.yarnpkg.com/@types/react/-/react-18.0.8.tgz#a051eb380a9fbcaa404550543c58e1cf5ce4ab87" 20 | integrity sha512-+j2hk9BzCOrrOSJASi5XiOyBbERk9jG5O73Ya4M0env5Ixi6vUNli4qy994AINcEF+1IEHISYFfIT4zwr++LKw== 21 | dependencies: 22 | "@types/prop-types" "*" 23 | "@types/scheduler" "*" 24 | csstype "^3.0.2" 25 | 26 | "@types/scheduler@*": 27 | version "0.16.2" 28 | resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.2.tgz#1a62f89525723dde24ba1b01b092bf5df8ad4d39" 29 | integrity sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew== 30 | 31 | arrify@^1.0.0: 32 | version "1.0.1" 33 | resolved "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d" 34 | 35 | buffer-from@^1.0.0: 36 | version "1.1.0" 37 | resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.0.tgz#87fcaa3a298358e0ade6e442cfce840740d1ad04" 38 | 39 | csstype@^3.0.2: 40 | version "3.0.6" 41 | resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.0.6.tgz#865d0b5833d7d8d40f4e5b8a6d76aea3de4725ef" 42 | integrity sha512-+ZAmfyWMT7TiIlzdqJgjMb7S4f1beorDbWbsocyK4RaiqA5RTX3K14bnBWmmA9QEM0gRdsjyyrEmcyga8Zsxmw== 43 | 44 | diff@^3.1.0: 45 | version "3.5.0" 46 | resolved "https://registry.yarnpkg.com/diff/-/diff-3.5.0.tgz#800c0dd1e0a8bfbc95835c202ad220fe317e5a12" 47 | 48 | "js-tokens@^3.0.0 || ^4.0.0": 49 | version "4.0.0" 50 | resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" 51 | integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== 52 | 53 | loose-envify@^1.1.0: 54 | version "1.4.0" 55 | resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" 56 | integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== 57 | dependencies: 58 | js-tokens "^3.0.0 || ^4.0.0" 59 | 60 | make-error@^1.1.1: 61 | version "1.3.4" 62 | resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.4.tgz#19978ed575f9e9545d2ff8c13e33b5d18a67d535" 63 | 64 | minimist@0.0.8: 65 | version "0.0.8" 66 | resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d" 67 | 68 | minimist@^1.2.0: 69 | version "1.2.0" 70 | resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.0.tgz#a35008b20f41383eec1fb914f4cd5df79a264284" 71 | 72 | mkdirp@^0.5.1: 73 | version "0.5.1" 74 | resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903" 75 | dependencies: 76 | minimist "0.0.8" 77 | 78 | react-dom@^18.1.0: 79 | version "18.1.0" 80 | resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.1.0.tgz#7f6dd84b706408adde05e1df575b3a024d7e8a2f" 81 | integrity sha512-fU1Txz7Budmvamp7bshe4Zi32d0ll7ect+ccxNu9FlObT605GOEB8BfO4tmRJ39R5Zj831VCpvQ05QPBW5yb+w== 82 | dependencies: 83 | loose-envify "^1.1.0" 84 | scheduler "^0.22.0" 85 | 86 | react@^18.1.0: 87 | version "18.1.0" 88 | resolved "https://registry.yarnpkg.com/react/-/react-18.1.0.tgz#6f8620382decb17fdc5cc223a115e2adbf104890" 89 | integrity sha512-4oL8ivCz5ZEPyclFQXaNksK3adutVS8l2xzZU0cqEFrE9Sb7fC0EFK5uEk74wIreL1DERyjvsU915j1pcT2uEQ== 90 | dependencies: 91 | loose-envify "^1.1.0" 92 | 93 | scheduler@^0.22.0: 94 | version "0.22.0" 95 | resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.22.0.tgz#83a5d63594edf074add9a7198b1bae76c3db01b8" 96 | integrity sha512-6QAm1BgQI88NPYymgGQLCZgvep4FyePDWFpXVK+zNSUgHwlqpJy8VEh8Et0KxTACS4VWwMousBElAZOH9nkkoQ== 97 | dependencies: 98 | loose-envify "^1.1.0" 99 | 100 | source-map-support@^0.5.6: 101 | version "0.5.6" 102 | resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.6.tgz#4435cee46b1aab62b8e8610ce60f788091c51c13" 103 | dependencies: 104 | buffer-from "^1.0.0" 105 | source-map "^0.6.0" 106 | 107 | source-map@^0.6.0: 108 | version "0.6.1" 109 | resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" 110 | 111 | ts-node@^6.1.0: 112 | version "6.1.0" 113 | resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-6.1.0.tgz#a2c37a11fdb58e60eca887a1269b025cf4d2f8b8" 114 | dependencies: 115 | arrify "^1.0.0" 116 | diff "^3.1.0" 117 | make-error "^1.1.1" 118 | minimist "^1.2.0" 119 | mkdirp "^0.5.1" 120 | source-map-support "^0.5.6" 121 | yn "^2.0.0" 122 | 123 | typescript@^4.6.3: 124 | version "4.6.3" 125 | resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.6.3.tgz#eefeafa6afdd31d725584c67a0eaba80f6fc6c6c" 126 | integrity sha512-yNIatDa5iaofVozS/uQJEl3JRWLKKGJKh6Yaiv0GLGSuhpFJe7P3SbHZ8/yjAHRQwKRoA6YZqlfjXWmVzoVSMw== 127 | 128 | yn@^2.0.0: 129 | version "2.0.0" 130 | resolved "https://registry.yarnpkg.com/yn/-/yn-2.0.0.tgz#e5adabc8acf408f6385fc76495684c88e6af689a" 131 | --------------------------------------------------------------------------------