├── .commitlintrc.json ├── .editorconfig ├── .eslintrc.json ├── .github └── workflows │ └── node-ci.yml ├── .gitignore ├── .huskyrc ├── .lintstagedrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── babel.config.js ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── LayoutTree.js ├── index.js ├── util │ ├── context.js │ ├── full-tree.js │ └── use-object-state.js └── with-layout.js └── test ├── __snapshots__ └── index.test.js.snap └── index.test.js /.commitlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "@commitlint/config-conventional" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 4 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [package.json] 13 | indent_size = 2 14 | 15 | [{*.md,*.snap}] 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "env": { 4 | "browser": true, 5 | "node": true 6 | }, 7 | "extends": [ 8 | "@moxy/eslint-config-base/esm", 9 | "@moxy/eslint-config-babel", 10 | "@moxy/eslint-config-react", 11 | "@moxy/eslint-config-jest" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /.github/workflows/node-ci.yml: -------------------------------------------------------------------------------- 1 | name: Node CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | 7 | check: 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | node-version: ['12', '14'] 12 | name: "[v${{ matrix.node-version }}] check" 13 | 14 | steps: 15 | - name: Checkout code 16 | uses: actions/checkout@v1 17 | 18 | - name: Use Node.js ${{ matrix.node-version }} 19 | uses: actions/setup-node@v1 20 | with: 21 | node-version: ${{ matrix.node-version }} 22 | 23 | - name: Install dependencies 24 | run: | 25 | npm ci 26 | 27 | - name: Run lint & tests 28 | env: 29 | CI: 1 30 | run: | 31 | npm run lint 32 | npm t 33 | 34 | - name: Submit coverage 35 | uses: codecov/codecov-action@v1 36 | with: 37 | token: ${{ secrets.CODECOV_TOKEN }} 38 | fail_ci_if_error: true 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log* 3 | coverage 4 | lib/ 5 | es/ 6 | -------------------------------------------------------------------------------- /.huskyrc: -------------------------------------------------------------------------------- 1 | { 2 | "hooks": { 3 | "pre-commit": "lint-staged", 4 | "commit-msg": "commitlint -E HUSKY_GIT_PARAMS" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.lintstagedrc: -------------------------------------------------------------------------------- 1 | { 2 | "*.js": "eslint" 3 | } 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | ### [2.2.3](https://github.com/moxystudio/next-layout/compare/v2.2.2...v2.2.3) (2021-03-23) 6 | 7 | ### [2.2.2](https://github.com/moxystudio/next-layout/compare/v2.2.1...v2.2.2) (2020-08-29) 8 | 9 | 10 | ### Bug Fixes 11 | 12 | * do not forward data-layout-page-key ([602bfbf](https://github.com/moxystudio/next-layout/commit/602bfbfc06e2372f8fb5ed5fd735a8713895befb)) 13 | 14 | ### [2.2.1](https://github.com/moxystudio/next-layout/compare/v2.2.0...v2.2.1) (2020-08-26) 15 | 16 | 17 | ### Bug Fixes 18 | 19 | * avoid react warning if page component is not wrapped ([5cc5b8d](https://github.com/moxystudio/next-layout/commit/5cc5b8d50647e050e5916c0ebb83e4cbf6cf3774)) 20 | 21 | ## [2.2.0](https://github.com/moxystudio/next-layout/compare/v2.1.6...v2.2.0) (2020-08-24) 22 | 23 | 24 | ### Features 25 | 26 | * add pageKey ([c31aace](https://github.com/moxystudio/next-layout/commit/c31aacefe56c4bfc3b3dd2f2d2ac4144fb05f807)) 27 | 28 | ### [2.1.6](https://github.com/moxystudio/next-layout/compare/v2.1.5...v2.1.6) (2020-08-24) 29 | 30 | 31 | ### Bug Fixes 32 | 33 | * fix previous commit ([760c960](https://github.com/moxystudio/next-layout/commit/760c9609eed89451717e0c2a803cc9d59446127f)) 34 | 35 | ### [2.1.5](https://github.com/moxystudio/next-layout/compare/v2.1.4...v2.1.5) (2020-08-24) 36 | 37 | 38 | ### Bug Fixes 39 | 40 | * ignore updates to layout if not the active component ([428f76e](https://github.com/moxystudio/next-layout/commit/428f76ea7f3e35f631731e57d47ae2c0c5879ff6)) 41 | 42 | ### [2.1.4](https://github.com/moxystudio/next-layout/compare/v2.1.3...v2.1.4) (2020-08-15) 43 | 44 | 45 | ### Bug Fixes 46 | 47 | * remove check due to false positive with fast-refresh ([06de614](https://github.com/moxystudio/next-layout/commit/06de61457f2c8a1376a80ee8458a5512ee0a28d7)) 48 | 49 | ### [2.1.3](https://github.com/moxystudio/next-layout/compare/v2.1.2...v2.1.3) (2020-07-31) 50 | 51 | 52 | ### Bug Fixes 53 | 54 | * detect if a page is being wrongly used inside the tree ([#4](https://github.com/moxystudio/next-layout/issues/4)) ([8ecab66](https://github.com/moxystudio/next-layout/commit/8ecab66b021db687ceca437fda750d7ac1b9298a)) 55 | 56 | ### [2.1.2](https://github.com/moxystudio/next-layout/compare/v2.1.0...v2.1.2) (2020-03-10) 57 | 58 | 59 | ### Bug Fixes 60 | 61 | * fix setLayoutState not working correctly ([7758727](https://github.com/moxystudio/next-layout/commit/775872704dda832a49e83bd16a4cc8a7746b2a46)) 62 | * page not rendered correctly when there's no layout ([65ef45d](https://github.com/moxystudio/next-layout/commit/65ef45d49d443b2b73cfdd733b4f9dcb7410e050)) 63 | 64 | ### [2.1.1](https://github.com/moxystudio/next-layout/compare/v2.1.0...v2.1.1) (2020-03-03) 65 | 66 | 67 | ### Bug Fixes 68 | 69 | * page not rendered correctly when there's no layout ([65ef45d](https://github.com/moxystudio/next-layout/commit/65ef45d49d443b2b73cfdd733b4f9dcb7410e050)) 70 | 71 | ## [2.1.0](https://github.com/moxystudio/next-layout/compare/v2.0.0...v2.1.0) (2020-03-03) 72 | 73 | 74 | ### Features 75 | 76 | * better error when no layout tree was found ([604ac30](https://github.com/moxystudio/next-layout/commit/604ac3044946e49b7cb11b46c514f396479861ff)) 77 | 78 | ## [2.0.0](https://github.com/moxystudio/next-layout/compare/v1.0.0...v2.0.0) (2020-02-27) 79 | 80 | 81 | ### ⚠ BREAKING CHANGES 82 | 83 | * the API has greatly changed, please check the README 84 | 85 | ### Features 86 | 87 | * add support for nested layouts ([#2](https://github.com/moxystudio/next-layout/issues/2)) ([1898f83](https://github.com/moxystudio/next-layout/commit/1898f83bb68a05ebda1c493f51ab102527a5c884)) 88 | 89 | ## 1.0.0 (2020-02-05) 90 | 91 | 92 | ### Features 93 | 94 | * initial implementation ([#1](https://github.com/moxystudio/next-layout/issues/1)) ([ef27429](https://github.com/moxystudio/next-layout/commit/ef27429742c5d7b99f1ff3a78fa7a973f61df4b3)) 95 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Made With MOXY Lda 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 13 | all 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 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # next-layout 2 | 3 | [![NPM version][npm-image]][npm-url] [![Downloads][downloads-image]][npm-url] [![Build Status][build-status-image]][build-status-url] [![Coverage Status][codecov-image]][codecov-url] [![Dependency status][david-dm-image]][david-dm-url] [![Dev Dependency status][david-dm-dev-image]][david-dm-dev-url] 4 | 5 | [npm-url]:https://npmjs.org/package/@moxy/next-layout 6 | [downloads-image]:https://img.shields.io/npm/dm/@moxy/next-layout.svg 7 | [npm-image]:https://img.shields.io/npm/v/@moxy/next-layout.svg 8 | [build-status-url]:https://github.com/moxystudio/next-layout/actions 9 | [build-status-image]:https://img.shields.io/github/workflow/status/moxystudio/next-layout/Node%20CI/master 10 | [codecov-url]:https://codecov.io/gh/moxystudio/next-layout 11 | [codecov-image]:https://img.shields.io/codecov/c/github/moxystudio/next-layout/master.svg 12 | [david-dm-url]:https://david-dm.org/moxystudio/next-layout 13 | [david-dm-image]:https://img.shields.io/david/moxystudio/next-layout.svg 14 | [david-dm-dev-url]:https://david-dm.org/moxystudio/next-layout?type=dev 15 | [david-dm-dev-image]:https://img.shields.io/david/dev/moxystudio/next-layout.svg 16 | 17 | Add persistent and nested layouts to your Next.js projects in a declarative way. 18 | 19 | ## Installation 20 | 21 | ```sh 22 | $ npm install @moxy/next-layout 23 | ``` 24 | 25 | This library is written in modern JavaScript and is published in both CommonJS and ES module transpiled variants. If you target older browsers please make sure to transpile accordingly. 26 | 27 | ## Motivation 28 | 29 | Next.js projects usually have the need to have one or more layouts. Layouts are the "shell" of your app and usually contain navigation elements, such as an header and a footer. In more complex projects, you might also need to have nested layouts which are often associated with nested routes. 30 | 31 | In the ideal scenario, each page would be able to say which layout they want to use, including tweaking its properties dynamically, such as `variant="light"`. However, we also want to keep the layout persistent in the React tree, to avoid having to remount it every time a user navigate between pages. 32 | 33 | Historically, projects overlook the need of multiple layouts or the ability to change layout props between pages. They start off with a simple layout and only later they handle this need, often with poor and non-scalable solutions. 34 | 35 | This library solves the need for multi-layouts and changing layout props dynamically in a consistent and reusable way. 36 | 37 | ## Usage 38 | 39 | Setup `` in your `pages/_app.js` component: 40 | 41 | ```js 42 | import React from 'react'; 43 | import { LayoutTree } from '@moxy/next-layout'; 44 | 45 | const App = ({ Component, pageProps }) => ( 46 | 49 | ); 50 | 51 | export default App; 52 | ``` 53 | 54 | ...and then use `withLayout` in your page components, e.g.: in `pages/about.js`: 55 | 56 | ```js 57 | import React from 'react'; 58 | import { withLayout } from '@moxy/next-layout'; 59 | import { PrimaryLayout } from '../components'; 60 | import styles from './about.module.css'; 61 | 62 | const About = () => ( 63 |
64 |

About

65 |
66 | ); 67 | 68 | export default withLayout()(About); 69 | ``` 70 | 71 | ℹ️ The `PrimaryLayout` component will receive the page to be rendered as the `children` prop. 72 | 73 | ### Nested layouts 74 | 75 | Nested layouts are as easy as nesting them in the `withLayout`. Let's say that you have two account pages, `pages/account/profile.js` and `pages/account/settings.js`, and you want them to be wrapped by an `AccountLayout`. You would define the pages like so: 76 | 77 | ```js 78 | // pages/account/profile.js 79 | import React from 'react'; 80 | import { withLayout } from '@moxy/next-layout'; 81 | import { PrimaryLayout, AccountLayout } from '../components'; 82 | import styles from './.account-profile.module.css'; 83 | 84 | const AccountProfile = () => ( 85 |
86 |

Account Profile

87 |
88 | ); 89 | 90 | export default withLayout( 91 | 92 | 93 | 94 | )(AccountProfile); 95 | ``` 96 | 97 | ```js 98 | // pages/account/settings.js 99 | import React from 'react'; 100 | import { withLayout } from '@moxy/next-layout'; 101 | import { PrimaryLayout, AccountLayout } from '../components'; 102 | import styles from './account-settings.module.css'; 103 | 104 | const AccountSettings = () => ( 105 |
106 |

Account Settings

107 |
108 | ); 109 | 110 | export default withLayout( 111 | 112 | 113 | 114 | )(AccountSettings); 115 | ``` 116 | 117 | ℹ️ The `PrimaryLayout` component will receive `AccountLayout` as a children, which in turn will receive the page as children too. 118 | 119 | ℹ️ You could create a `withAccountLayout` HOC to avoid repeating the layout tree in every account page. 120 | 121 | ⚠️ The layout tree specified in `withLayout` must be a unary tree, that is, a tree where nodes just have one child. 122 | 123 | ## API 124 | 125 | `@moxy/next-layout` exposes a `` component and a `withLayout` HOC to be used in pages. 126 | 127 | ### <LayoutTree> 128 | 129 | A component that infers the layout tree based on what the active page specifies. It keeps the layout persistent between page transitions whenever possible (e.g.: when the layout is the same). 130 | 131 | Here's the list of props it supports: 132 | 133 | #### Component 134 | 135 | Type: `ReactElementType` 136 | 137 | The page component, which maps to your App `Component` prop. 138 | 139 | #### pageProps 140 | 141 | Type: `object` 142 | 143 | The page component props, which maps to your App `pageProps` prop. 144 | 145 | #### pageKey? 146 | 147 | Type: `string` 148 | 149 | The page key used to uniquely identify this page. Useful for dynamic routes, where the `Component` is the same, but you still want the page to be re-mounted. For such cases, you may use `router.asPath.replace(/\?.+/, '')`. 150 | 151 | #### defaultLayout 152 | 153 | Type: `ReactElement` 154 | 155 | The default layout tree to be used when a child page doesn't explicitly sets one. 156 | 157 | ```js 158 | // pages/_app.js 159 | import React from 'react'; 160 | import { LayoutTree } from '@moxy/next-layout'; 161 | import { PrimaryLayout } from '../components'; 162 | 163 | const App = ({ Component, pageProps }) => ( 164 | } /> 168 | ); 169 | 170 | export default App; 171 | ``` 172 | 173 | #### children 174 | 175 | Type: `function` 176 | 177 | A [render prop](https://reactjs.org/docs/render-props.html) to override the default render behavior, which just regularly renders the tree. 178 | 179 | Its signature is `(tree) => `, where: `tree` is the React's tree composed by layout elements and a leaf page element. 180 | 181 | This might be useful if you want to add animations between page transitions. 182 | 183 | ### withLayout(mapLayoutStateToLayoutTree?, initialLayoutState?)(Page) 184 | 185 | Sets up a `Page` component with the ability to specify which layout tree to use. Moreover, it injects a `setLayoutState` prop so that you may dynamically update the layout tree. 186 | 187 | #### mapLayoutStateToLayoutTree 188 | 189 | Type: `ReactElement` or `function` 190 | 191 | In simple cases, you may define a "static" layout tree, like so: 192 | 193 | ```js 194 | export default withLayout()(Home); 195 | ``` 196 | 197 | However, you might have external props, component state or other mutations influencing the layout tree. In those cases, you may pass a function that maps **layout state** into a tree, with the following signature: `(layoutState) => `. Here's an example: 198 | 199 | ```js 200 | const mapLayoutStateToLayoutTree = ({ variant }) => ; 201 | 202 | export default withLayout(mapLayoutStateToLayoutTree, { variant: 'light' })(Home); 203 | ``` 204 | 205 | The function is run initially and every time the *layout state* changes. 206 | 207 | #### initialLayoutState 208 | 209 | Type: `object` or `function` 210 | 211 | The initial **layout state** to be passed to `mapLayoutStateToLayoutTree`. If your initial *layout state* depends on the props you receive, you may pass a function with the following signature: `(props) => `. 212 | 213 | #### Page 214 | 215 | Type: `ReactElementType` 216 | 217 | The page component to wrap. 218 | 219 | #### Injected setLayoutState 220 | 221 | Type: `function` 222 | 223 | Allows dynamic changes to the layout state. Has the following signature: `(newState | updater?)`. 224 | 225 | The behavior of `setLayoutState` is exactly the same as [`setState`](https://reactjs.org/docs/react-component.html#setstate) of class components: it merges properties and it supports both an object or an updater function. 226 | 227 | ```js 228 | // pages/about.js 229 | import React, { useCallback } from 'react'; 230 | import { withLayout } from '@moxy/next-layout'; 231 | import { PrimaryLayout } from '../components'; 232 | 233 | import styles from './about.module.css'; 234 | 235 | const About = ({ setLayoutState }) => { 236 | const handleSetToDark = useCallback(() => { 237 | setLayoutState({ variant: 'dark' }); 238 | // ..or setLayoutState((layoutState) => ({ variant: 'dark' })); 239 | }, [setLayoutState]); 240 | 241 | return ( 242 |
243 |

About

244 | 245 |
246 | ); 247 | }; 248 | 249 | const mapLayoutStateToLayoutTree = ({ variant }) => ; 250 | 251 | export default withLayout(mapLayoutStateToLayoutTree, { variant: 'light' })(About); 252 | ``` 253 | 254 | ## Tests 255 | 256 | ```sh 257 | $ npm test 258 | $ npm test -- --watch # during development 259 | ``` 260 | 261 | ## License 262 | 263 | Released under the [MIT License](https://www.opensource.org/licenses/mit-license.php). 264 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = (api) => { 4 | api.cache(true); 5 | 6 | return { 7 | ignore: process.env.BABEL_ENV ? ['**/*.test.js', '**/__snapshots__', '**/__mocks__', '**/__fixtures__'] : [], 8 | presets: [ 9 | ['@moxy/babel-preset/lib', { react: true }], 10 | ], 11 | }; 12 | }; 13 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { compose, baseConfig } = require('@moxy/jest-config-base'); 4 | const withWeb = require('@moxy/jest-config-web'); 5 | const { withEnzymeWeb } = require('@moxy/jest-config-enzyme'); 6 | 7 | module.exports = compose( 8 | baseConfig(), 9 | withWeb(), 10 | withEnzymeWeb('enzyme-adapter-react-16'), // ⚠️ Always after .withWeb 11 | ); 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@moxy/next-layout", 3 | "version": "2.2.3", 4 | "description": "Add persistent and nested layouts to your Next.js projects in a declarative way", 5 | "main": "lib/index.js", 6 | "module": "es/index.js", 7 | "files": [ 8 | "lib", 9 | "es", 10 | "!**/*.test.js", 11 | "!**/__snapshots__", 12 | "!**/__mocks__" 13 | ], 14 | "homepage": "https://github.com/moxystudio/next-layout#readme", 15 | "author": "André Cruz ", 16 | "license": "MIT", 17 | "repository": { 18 | "type": "git", 19 | "url": "git+https://github.com/moxystudio/next-layout.git" 20 | }, 21 | "keywords": [ 22 | "react", 23 | "next", 24 | "next.js", 25 | "layout", 26 | "page", 27 | "persistent", 28 | "nested", 29 | "nested layouts" 30 | ], 31 | "bugs": { 32 | "url": "https://github.com/moxystudio/next-layout/issues" 33 | }, 34 | "scripts": { 35 | "build:commonjs": "cross-env BABEL_ENV=commonjs babel src -d lib --delete-dir-on-start", 36 | "build:es": "cross-env BABEL_ENV=es babel src -d es --delete-dir-on-start", 37 | "build": "npm run build:commonjs && npm run build:es", 38 | "test": "jest", 39 | "lint": "eslint --ignore-path .gitignore .", 40 | "prerelease": "npm t && npm run lint && npm run build", 41 | "release": "standard-version", 42 | "postrelease": "git push --follow-tags origin HEAD && npm publish" 43 | }, 44 | "peerDependencies": { 45 | "react": ">= 16.8.0 < 18" 46 | }, 47 | "dependencies": { 48 | "hoist-non-react-statics": "^3.3.2", 49 | "memoize-one": "^5.1.1", 50 | "prop-types": "^15.7.2" 51 | }, 52 | "devDependencies": { 53 | "@babel/cli": "^7.13.10", 54 | "@babel/core": "^7.13.10", 55 | "@commitlint/config-conventional": "^12.0.1", 56 | "@moxy/babel-preset": "^3.2.1", 57 | "@moxy/eslint-config-babel": "^13.0.0", 58 | "@moxy/eslint-config-base": "^13.0.0", 59 | "@moxy/eslint-config-jest": "^13.0.0", 60 | "@moxy/eslint-config-react": "^13.0.0", 61 | "@moxy/jest-config-base": "^5.2.0", 62 | "@moxy/jest-config-enzyme": "^5.2.0", 63 | "@moxy/jest-config-web": "^5.2.0", 64 | "classnames": "^2.2.6", 65 | "commitlint": "^12.0.1", 66 | "cross-env": "^7.0.2", 67 | "enzyme-adapter-react-16": "^1.15.6", 68 | "eslint": "^7.22.0", 69 | "husky": "^4.0.10", 70 | "jest": "^26.0.0", 71 | "lint-staged": "^10.5.4", 72 | "react": "^16.8.6", 73 | "react-dom": "^16.8.6", 74 | "standard-version": "^9.1.1" 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/LayoutTree.js: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import memoizeOne from 'memoize-one'; 4 | import LayoutContext from './util/context'; 5 | import createFullTree from './util/full-tree'; 6 | import { getInitialLayoutTree, isComponentWrapped } from './with-layout'; 7 | 8 | const LayoutProvider = LayoutContext.Provider; 9 | 10 | export default class LayoutTree extends PureComponent { 11 | static propTypes = { 12 | Component: PropTypes.elementType.isRequired, 13 | pageProps: PropTypes.object, 14 | pageKey: PropTypes.string, 15 | defaultLayout: PropTypes.element, 16 | children: PropTypes.func, 17 | }; 18 | 19 | static defaultProps = { 20 | children: (rootNode) => rootNode, 21 | }; 22 | 23 | static getDerivedStateFromProps(props, state) { 24 | const { Component, pageProps, pageKey } = props; 25 | 26 | const didPageChange = Component !== state.Component || pageKey !== state.pageKey; 27 | const layoutTree = didPageChange ? getInitialLayoutTree(Component, pageProps) : state.layoutTree; 28 | 29 | return { 30 | Component, 31 | pageKey, 32 | layoutTree, 33 | }; 34 | } 35 | 36 | state = {}; 37 | 38 | // eslint-disable-next-line react/sort-comp 39 | updateLayoutTree = (layoutTree) => this.setState({ layoutTree }); 40 | 41 | getProviderValue = memoizeOne((Component, pageKey) => ({ 42 | Component, 43 | pageKey, 44 | updateLayoutTree: this.updateLayoutTree, 45 | })); 46 | 47 | render() { 48 | const { defaultLayout, Component, pageProps, pageKey, children: render } = this.props; 49 | const { layoutTree } = this.state; 50 | 51 | // Do not forward pageKey if the component is not wrapped, otherwise it would cause 52 | // an error if props were spreaded into a DOM element: 53 | // "React does not recognize the `pageKey` prop on a DOM element". 54 | const isWrapped = isComponentWrapped(Component); 55 | const page = ; 56 | 57 | const fullTree = createFullTree(layoutTree ?? defaultLayout, page); 58 | const providerValue = this.getProviderValue(Component, pageKey); 59 | 60 | return ( 61 | 62 | { render(fullTree) } 63 | 64 | ); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export { default as LayoutTree } from './LayoutTree'; 2 | export { default as withLayout } from './with-layout'; 3 | -------------------------------------------------------------------------------- /src/util/context.js: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | 3 | const LayoutContext = createContext(); 4 | 5 | export default LayoutContext; 6 | -------------------------------------------------------------------------------- /src/util/full-tree.js: -------------------------------------------------------------------------------- 1 | import { isValidElement, cloneElement } from 'react'; 2 | 3 | const validateLayoutTree = (node) => { 4 | /* istanbul ignore if */ 5 | if (process.env.NODE_ENV === 'production') { 6 | return; 7 | } 8 | 9 | if (!isValidElement(node)) { 10 | throw new TypeError('Only unary trees composed by react elements are supported as layouts'); 11 | } 12 | 13 | if (node.props.children) { 14 | validateLayoutTree(node.props.children); 15 | } 16 | }; 17 | 18 | const addPageToLayoutTree = (layoutNode, page) => cloneElement(layoutNode, { 19 | children: layoutNode.props.children ? addPageToLayoutTree(layoutNode.props.children, page) : page, 20 | }); 21 | 22 | const createFullTree = (layoutTree, page) => { 23 | if (!layoutTree) { 24 | return page; 25 | } 26 | 27 | validateLayoutTree(layoutTree); 28 | 29 | return addPageToLayoutTree(layoutTree, page); 30 | }; 31 | 32 | export default createFullTree; 33 | -------------------------------------------------------------------------------- /src/util/use-object-state.js: -------------------------------------------------------------------------------- 1 | import { useState, useRef } from 'react'; 2 | 3 | const createSetObjectState = (setState) => (newState) => { 4 | const updater = typeof newState === 'function' ? newState : () => newState; 5 | 6 | return setState((state) => ({ 7 | ...state, 8 | ...updater(state), 9 | })); 10 | }; 11 | 12 | const useObjectState = (initialState) => { 13 | const [state, setState] = useState(() => initialState ?? {}); 14 | const setObjectStateRef = useRef(); 15 | 16 | if (!setObjectStateRef.current) { 17 | setObjectStateRef.current = createSetObjectState(setState); 18 | } 19 | 20 | return [state, setObjectStateRef.current]; 21 | }; 22 | 23 | export default useObjectState; 24 | -------------------------------------------------------------------------------- /src/with-layout.js: -------------------------------------------------------------------------------- 1 | import React, { useContext, useMemo, forwardRef, useEffect, useRef } from 'react'; 2 | import hoistNonReactStatics from 'hoist-non-react-statics'; 3 | import useObjectState from './util/use-object-state'; 4 | import LayoutContext from './util/context'; 5 | 6 | const toFunction = (fn) => typeof fn === 'function' ? fn : () => fn; 7 | 8 | const getInitialLayoutTreeSymbol = Symbol('getInitialLayoutTreeSymbol'); 9 | 10 | const withLayout = (mapLayoutStateToLayoutTree, mapPropsToInitialLayoutState) => { 11 | const shouldInjectSetLayoutState = typeof mapLayoutStateToLayoutTree === 'function'; 12 | 13 | mapLayoutStateToLayoutTree = toFunction(mapLayoutStateToLayoutTree); 14 | mapPropsToInitialLayoutState = toFunction(mapPropsToInitialLayoutState); 15 | 16 | return (Component) => { 17 | const WithLayout = forwardRef((_props, ref) => { 18 | const { pageKey, props } = useMemo(() => { 19 | const { pageKey, ...props } = _props; 20 | 21 | return { pageKey, props }; 22 | }, [_props]); 23 | 24 | const initialLayoutStateRef = useRef(); 25 | 26 | if (!initialLayoutStateRef.current) { 27 | initialLayoutStateRef.current = mapPropsToInitialLayoutState(props); 28 | } 29 | 30 | const layoutProviderValue = useContext(LayoutContext); 31 | 32 | // Check if was not added to the app (missing provider). 33 | if (process.env.NODE_ENV !== 'production' && !layoutProviderValue) { 34 | throw new Error('It seems you forgot to include in your app'); 35 | } 36 | 37 | const { updateLayoutTree, Component: ProviderComponent, pageKey: providerPageKey } = layoutProviderValue; 38 | const [layoutState, setLayoutState] = useObjectState(initialLayoutStateRef.current); 39 | 40 | useEffect(() => { 41 | if (layoutState !== initialLayoutStateRef.current && 42 | ProviderComponent === WithLayout && 43 | providerPageKey === pageKey 44 | ) { 45 | updateLayoutTree(mapLayoutStateToLayoutTree(layoutState)); 46 | } 47 | }, [layoutState, updateLayoutTree, ProviderComponent, providerPageKey, pageKey]); 48 | 49 | return useMemo(() => ( 50 | 54 | ), [ref, setLayoutState, props]); 55 | }); 56 | 57 | const getInitialLayoutTree = (props) => { 58 | const layoutState = mapPropsToInitialLayoutState(props); 59 | 60 | return mapLayoutStateToLayoutTree(layoutState); 61 | }; 62 | 63 | Object.defineProperty(WithLayout, getInitialLayoutTreeSymbol, { value: getInitialLayoutTree }); 64 | 65 | WithLayout.displayName = `WithLayout(${Component.displayName || Component.name || 'Component'})`; 66 | hoistNonReactStatics(WithLayout, Component); 67 | 68 | return WithLayout; 69 | }; 70 | }; 71 | 72 | export const getInitialLayoutTree = (Component, pageProps) => Component[getInitialLayoutTreeSymbol]?.(pageProps); 73 | 74 | export const isComponentWrapped = (Component) => !!Component[getInitialLayoutTreeSymbol]; 75 | 76 | export default withLayout; 77 | -------------------------------------------------------------------------------- /test/__snapshots__/index.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`should allow passing a custom render prop to LayoutTree 1`] = ` 4 | 18 | 19 |
20 | 23 | 26 |

27 | Home 28 |

29 |
30 |
31 |
32 |
33 |
34 | `; 35 | 36 | exports[`should allow passing a custom render prop to LayoutTree 2`] = ` 37 | 38 | 41 | 42 | `; 43 | 44 | exports[`should ignore setLayoutState calls if Component's pageKey is not the active pageKey of LayoutTree 1`] = ` 45 | 55 | 58 |
59 | 63 | 66 |

67 | Foo 68 |

69 |
70 |
71 |
72 |
73 | 76 | 80 |

81 | Foo 82 |

83 |
84 |
85 |
86 | `; 87 | 88 | exports[`should ignore setLayoutState calls if page is not the active Component of LayoutTree 1`] = ` 89 | 98 | 101 |
102 | 103 | 106 |

107 | Home 108 |

109 |
110 |
111 |
112 |
113 | 114 | 117 |

118 | Foo 119 |

120 |
121 |
122 |
123 | `; 124 | 125 | exports[`should not forward pageKey if component in not wrapped with HOC 1`] = ` 126 | 135 | 139 |

140 | Home 141 |

142 |
143 |
144 | `; 145 | 146 | exports[`should render a layout tree correctly based the initial layout state (function) 1`] = ` 147 | 161 | 164 |
165 | 168 | 172 |

173 | Home 174 |

175 |
176 |
177 |
178 |
179 |
180 | `; 181 | 182 | exports[`should render a layout tree correctly based the initial layout state 1`] = ` 183 | 192 | 195 |
196 | 197 | 200 |

201 | Home 202 |

203 |
204 |
205 |
206 |
207 |
208 | `; 209 | 210 | exports[`should render a one level deep layout tree correctly 1`] = ` 211 | 225 | 226 |
227 | 230 | 233 |

234 | Home 235 |

236 |
237 |
238 |
239 |
240 |
241 | `; 242 | 243 | exports[`should render a two level deep layout tree correctly 1`] = ` 244 | 258 | 259 |
260 | 261 |
262 | 265 | 268 |

269 | Home 270 |

271 |
272 |
273 |
274 |
275 |
276 |
277 |
278 | `; 279 | 280 | exports[`should render default layout 1`] = ` 281 | } 284 | pageProps={ 285 | Object { 286 | "foo": "bar", 287 | } 288 | } 289 | > 290 | 291 |
292 | 295 |

296 | Home 297 |

298 |
299 |
300 |
301 |
302 | `; 303 | 304 | exports[`should render no layout 1`] = ` 305 | 313 | 316 |

317 | Home 318 |

319 |
320 |
321 | `; 322 | 323 | exports[`should update the layout tree correctly if Component changes 1`] = ` 324 | 333 | 336 |
337 | 338 | 339 |

340 | Home 341 |

342 |
343 |
344 |
345 |
346 |
347 | `; 348 | 349 | exports[`should update the layout tree correctly if Component changes 2`] = ` 350 | 359 | 362 |
363 | 364 | 365 |

366 | Foo 367 |

368 |
369 |
370 |
371 |
372 |
373 | `; 374 | 375 | exports[`should update the layout tree correctly if pageKey changes 1`] = ` 376 | 386 | 389 |
390 | 394 | 397 |

398 | Home 399 |

400 |
401 |
402 |
403 |
404 |
405 | `; 406 | 407 | exports[`should update the layout tree correctly if pageKey changes 2`] = ` 408 | 418 | 421 |
422 | 426 | 429 |

430 | Home 431 |

432 |
433 |
434 |
435 |
436 |
437 | `; 438 | -------------------------------------------------------------------------------- /test/index.test.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { mount } from 'enzyme'; 3 | import { withLayout, LayoutTree } from '../src'; 4 | 5 | const PrimaryLayout = ({ children }) =>
{ children }
; 6 | const Home = () =>

Home

; 7 | 8 | afterEach(() => { 9 | console.error.mockRestore?.(); 10 | }); 11 | 12 | it('should render no layout', () => { 13 | const wrapper = mount( 14 | , 17 | ); 18 | 19 | expect(wrapper).toMatchSnapshot(); 20 | }); 21 | 22 | it('should render default layout', () => { 23 | const wrapper = mount( 24 | } />, 28 | ); 29 | 30 | expect(wrapper).toMatchSnapshot(); 31 | }); 32 | 33 | it('should render a one level deep layout tree correctly', () => { 34 | const EnhancedHome = withLayout()(Home); 35 | 36 | const wrapper = mount( 37 | , 40 | ); 41 | 42 | expect(wrapper).toMatchSnapshot(); 43 | }); 44 | 45 | it('should render a two level deep layout tree correctly', () => { 46 | const AccountLayout = ({ children }) =>
{ children }
; 47 | 48 | const layout = ( 49 | 50 | 51 | 52 | ); 53 | 54 | const EnhancedAccountInfo = withLayout(layout)(Home); 55 | 56 | const wrapper = mount( 57 | , 60 | ); 61 | 62 | expect(wrapper).toMatchSnapshot(); 63 | }); 64 | 65 | it('should call layout\'s and page\'s render just once', () => { 66 | const PrimaryLayoutMock = jest.fn(PrimaryLayout); 67 | const HomeMock = jest.fn(Home); 68 | 69 | const EnhancedHomeMock = withLayout()(HomeMock); 70 | 71 | mount(); 72 | 73 | expect(PrimaryLayoutMock).toHaveBeenCalledTimes(1); 74 | expect(HomeMock).toHaveBeenCalledTimes(1); 75 | }); 76 | 77 | it('should render a layout tree correctly based the initial layout state', () => { 78 | const mapLayoutStateToLayoutTree = jest.fn(({ variant }) => ); 79 | const EnhancedHome = withLayout(mapLayoutStateToLayoutTree, { variant: 'light' })(Home); 80 | 81 | const wrapper = mount(); 82 | 83 | expect(wrapper).toMatchSnapshot(); 84 | expect(mapLayoutStateToLayoutTree).toHaveBeenCalledTimes(1); 85 | expect(mapLayoutStateToLayoutTree).toHaveBeenLastCalledWith({ variant: 'light' }); 86 | }); 87 | 88 | it('should render a layout tree correctly based the initial layout state (function)', () => { 89 | const mapLayoutStateToLayoutTree = jest.fn(({ variant }) => ); 90 | const mapPropsToInitialLayoutState = jest.fn(({ light }) => ({ variant: light ? 'light' : 'dark' })); 91 | 92 | const EnhancedHome = withLayout(mapLayoutStateToLayoutTree, mapPropsToInitialLayoutState)(Home); 93 | 94 | const wrapper = mount( 95 | , 98 | ); 99 | 100 | expect(wrapper).toMatchSnapshot(); 101 | expect(mapLayoutStateToLayoutTree).toHaveBeenCalledTimes(1); 102 | expect(mapLayoutStateToLayoutTree).toHaveBeenLastCalledWith({ variant: 'light' }); 103 | expect(mapPropsToInitialLayoutState).toHaveBeenCalledTimes(2); 104 | expect(mapPropsToInitialLayoutState).toHaveBeenLastCalledWith({ light: true }); 105 | }); 106 | 107 | it('should update the layout tree correctly if setLayoutState is called', () => { 108 | const Home = ({ setLayoutState }) => { 109 | useEffect(() => { 110 | setLayoutState({ variant: 'dark' }); 111 | }, [setLayoutState]); 112 | 113 | return

Home

; 114 | }; 115 | 116 | const PrimaryLayoutMock = jest.fn(PrimaryLayout); 117 | const HomeMock = jest.fn(Home); 118 | 119 | const mapLayoutStateToLayoutTree = jest.fn(({ variant }) => ); 120 | const EnhancedHomeMock = withLayout(mapLayoutStateToLayoutTree, { variant: 'light' })(HomeMock); 121 | 122 | mount(); 123 | 124 | expect(PrimaryLayoutMock).toHaveBeenCalledTimes(2); 125 | expect(PrimaryLayoutMock).toHaveBeenNthCalledWith(1, { children: expect.anything(), variant: 'light' }, {}); 126 | expect(PrimaryLayoutMock).toHaveBeenNthCalledWith(2, { children: expect.anything(), variant: 'dark' }, {}); 127 | expect(HomeMock).toHaveBeenCalledTimes(2); 128 | }); 129 | 130 | it('should update the layout tree correctly if Component changes', () => { 131 | const Foo = () =>

Foo

; 132 | 133 | const EnhancedHome = withLayout()(Home); 134 | const EnhancedFoo = withLayout()(Foo); 135 | 136 | const wrapper = mount(); 137 | 138 | expect(wrapper).toMatchSnapshot(); 139 | 140 | wrapper.setProps({ Component: EnhancedFoo }); 141 | 142 | expect(wrapper).toMatchSnapshot(); 143 | }); 144 | 145 | it('should update the layout tree correctly if pageKey changes', () => { 146 | let doSetLayoutState = true; 147 | 148 | const Home = ({ setLayoutState }) => { 149 | useEffect(() => { 150 | doSetLayoutState && setLayoutState({ variant: 'dark' }); 151 | }, [setLayoutState]); 152 | 153 | return

Home

; 154 | }; 155 | 156 | const mapLayoutStateToLayoutTree = jest.fn(({ variant }) => ); 157 | const EnhancedHome = withLayout(mapLayoutStateToLayoutTree, { variant: 'light' })(Home); 158 | 159 | const wrapper = mount(); 160 | 161 | expect(wrapper).toMatchSnapshot(); 162 | 163 | doSetLayoutState = false; 164 | wrapper.setProps({ pageKey: 'bar' }); 165 | 166 | expect(wrapper).toMatchSnapshot(); 167 | expect(mapLayoutStateToLayoutTree).toHaveBeenCalledTimes(3); 168 | }); 169 | 170 | it('should update the layout tree correctly if setLayoutState is called (function)', () => { 171 | expect.assertions(5); 172 | 173 | const Home = ({ setLayoutState }) => { 174 | useEffect(() => { 175 | setLayoutState((layoutState) => { 176 | expect(layoutState).toEqual({ variant: 'light' }); 177 | 178 | return { variant: 'dark' }; 179 | }); 180 | }, [setLayoutState]); 181 | 182 | return

Home

; 183 | }; 184 | 185 | const PrimaryLayoutMock = jest.fn(PrimaryLayout); 186 | const HomeMock = jest.fn(Home); 187 | 188 | const mapLayoutStateToLayoutTree = jest.fn(({ variant }) => ); 189 | const EnhancedHomeMock = withLayout(mapLayoutStateToLayoutTree, { variant: 'light' })(HomeMock); 190 | 191 | mount(); 192 | 193 | expect(PrimaryLayoutMock).toHaveBeenCalledTimes(2); 194 | expect(PrimaryLayoutMock).toHaveBeenNthCalledWith(1, { children: expect.anything(), variant: 'light' }, {}); 195 | expect(PrimaryLayoutMock).toHaveBeenNthCalledWith(2, { children: expect.anything(), variant: 'dark' }, {}); 196 | expect(HomeMock).toHaveBeenCalledTimes(2); 197 | }); 198 | 199 | it('should not inject setLayoutState to pages if it is not needed', () => { 200 | const HomeMock = jest.fn(Home); 201 | const EnhancedHome = withLayout()(HomeMock); 202 | 203 | mount(); 204 | 205 | expect(HomeMock).toHaveBeenCalledTimes(1); 206 | expect(HomeMock).toHaveBeenCalledWith({}, {}); 207 | }); 208 | 209 | it('should throw if layout tree is not unary', () => { 210 | jest.spyOn(console, 'error').mockImplementation(() => {}); 211 | 212 | const NewsletterForm = () =>
; 213 | const AccountLayout = ({ children }) =>
{ children }
; 214 | 215 | const layout = ( 216 | 217 | 218 | 219 | 220 | ); 221 | 222 | const EnhancedHome = withLayout(layout)(Home); 223 | 224 | expect(() => mount()).toThrow(/unary trees/i); 225 | }); 226 | 227 | it('should allow passing a custom render prop to LayoutTree', () => { 228 | const EnhancedHome = withLayout()(Home); 229 | 230 | const render = jest.fn((tree) => tree); 231 | 232 | const wrapper = mount( 233 | 236 | { render } 237 | , 238 | ); 239 | 240 | expect(wrapper).toMatchSnapshot(); 241 | expect(render).toHaveBeenCalledTimes(1); 242 | expect(render.mock.results[0].value).toMatchSnapshot(); 243 | }); 244 | 245 | it('should throw if LayoutTree was not rendered', () => { 246 | jest.spyOn(console, 'error').mockImplementation(() => {}); 247 | 248 | const EnhancedHome = withLayout()(Home); 249 | 250 | expect(() => { 251 | mount( 252 | , 253 | ); 254 | }).toThrow(/it seems you forgot to include/i); 255 | }); 256 | 257 | it('should ignore setLayoutState calls if page is not the active Component of LayoutTree', () => { 258 | jest.spyOn(console, 'error').mockImplementation(() => {}); 259 | 260 | const Foo = ({ setLayoutState }) => { 261 | useEffect(() => { 262 | setLayoutState({ variant: 'dark' }); 263 | }, [setLayoutState]); 264 | 265 | return

Foo

; 266 | }; 267 | 268 | const mapLayoutStateToLayoutTree = jest.fn(({ variant }) => ); 269 | const EnhancedHome = withLayout(mapLayoutStateToLayoutTree, { variant: 'light' })(Home); 270 | const EnhancedFoo = withLayout(mapLayoutStateToLayoutTree, { variant: 'light' })(Foo); 271 | 272 | const render = jest.fn((tree) => ( 273 | <> 274 | { tree } 275 | { } 276 | 277 | )); 278 | 279 | const wrapper = mount( 280 | 281 | { render } 282 | , 283 | ); 284 | 285 | expect(wrapper).toMatchSnapshot(); 286 | expect(mapLayoutStateToLayoutTree).toHaveBeenCalledTimes(1); 287 | }); 288 | 289 | it('should ignore setLayoutState calls if Component\'s pageKey is not the active pageKey of LayoutTree', () => { 290 | jest.spyOn(console, 'error').mockImplementation(() => {}); 291 | 292 | const Foo = ({ foo, setLayoutState }) => { 293 | useEffect(() => { 294 | foo && setLayoutState({ variant: 'dark' }); 295 | }, [foo, setLayoutState]); 296 | 297 | return

Foo

; 298 | }; 299 | 300 | const mapLayoutStateToLayoutTree = jest.fn(({ variant }) => ); 301 | const EnhancedFoo = withLayout(mapLayoutStateToLayoutTree, { variant: 'light' })(Foo); 302 | 303 | const render = jest.fn((tree) => ( 304 | <> 305 | { tree } 306 | { } 307 | 308 | )); 309 | 310 | const wrapper = mount( 311 | 312 | { render } 313 | , 314 | ); 315 | 316 | expect(wrapper).toMatchSnapshot(); 317 | expect(mapLayoutStateToLayoutTree).toHaveBeenCalledTimes(1); 318 | }); 319 | 320 | it('should not forward pageKey if component in not wrapped with HOC', () => { 321 | const wrapper = mount( 322 | , 326 | ); 327 | 328 | expect(wrapper).toMatchSnapshot(); 329 | }); 330 | --------------------------------------------------------------------------------