diff --git a/README.md b/README.md index 7068c1d..4a584df 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,12 @@ Type: `object` The page component props, which maps to your App `pageProps` prop. +#### pageKey? + +Type: `string` + +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(/\?.+/, '')`. + #### defaultLayout Type: `ReactElement` diff --git a/src/LayoutTree.js b/src/LayoutTree.js index d446b63..d08a48e 100644 --- a/src/LayoutTree.js +++ b/src/LayoutTree.js @@ -11,6 +11,7 @@ export default class LayoutTree extends PureComponent { static propTypes = { Component: PropTypes.elementType.isRequired, pageProps: PropTypes.object, + pageKey: PropTypes.string, defaultLayout: PropTypes.element, children: PropTypes.func, }; @@ -20,13 +21,14 @@ export default class LayoutTree extends PureComponent { }; static getDerivedStateFromProps(props, state) { - const { Component, pageProps } = props; + const { Component, pageProps, pageKey } = props; - const didPageChange = props.Component !== state.Component; + const didPageChange = Component !== state.Component || pageKey !== state.pageKey; const layoutTree = didPageChange ? getInitialLayoutTree(Component, pageProps) : state.layoutTree; return { Component, + pageKey, layoutTree, }; } @@ -36,18 +38,19 @@ export default class LayoutTree extends PureComponent { // eslint-disable-next-line react/sort-comp updateLayoutTree = (layoutTree) => this.setState({ layoutTree }); - getProviderValue = memoizeOne((Component) => ({ + getProviderValue = memoizeOne((Component, pageKey) => ({ Component, + pageKey, updateLayoutTree: this.updateLayoutTree, })); render() { - const { defaultLayout, Component, pageProps, children: render } = this.props; + const { defaultLayout, Component, pageProps, pageKey, children: render } = this.props; const { layoutTree } = this.state; - const page = ; + const page = ; const fullTree = createFullTree(layoutTree ?? defaultLayout, page); - const providerValue = this.getProviderValue(Component); + const providerValue = this.getProviderValue(Component, pageKey); return ( diff --git a/src/with-layout.js b/src/with-layout.js index 690b14c..cc747d1 100644 --- a/src/with-layout.js +++ b/src/with-layout.js @@ -14,7 +14,13 @@ const withLayout = (mapLayoutStateToLayoutTree, mapPropsToInitialLayoutState) => mapPropsToInitialLayoutState = toFunction(mapPropsToInitialLayoutState); return (Component) => { - const WithLayout = forwardRef((props, ref) => { + const WithLayout = forwardRef((_props, ref) => { + const { pageKey, props } = useMemo(() => { + const { pageKey, ...props } = _props; + + return { pageKey, props }; + }, [_props]); + const initialLayoutStateRef = useRef(); if (!initialLayoutStateRef.current) { @@ -28,14 +34,17 @@ const withLayout = (mapLayoutStateToLayoutTree, mapPropsToInitialLayoutState) => throw new Error('It seems you forgot to include in your app'); } - const { updateLayoutTree, Component: ProviderComponent } = layoutProviderValue; + const { updateLayoutTree, Component: ProviderComponent, pageKey: providerPageKey } = layoutProviderValue; const [layoutState, setLayoutState] = useObjectState(initialLayoutStateRef.current); useEffect(() => { - if (layoutState !== initialLayoutStateRef.current && ProviderComponent === WithLayout) { + if (layoutState !== initialLayoutStateRef.current && + ProviderComponent === WithLayout && + providerPageKey === pageKey + ) { updateLayoutTree(mapLayoutStateToLayoutTree(layoutState)); } - }, [layoutState, updateLayoutTree, ProviderComponent]); + }, [layoutState, updateLayoutTree, ProviderComponent, providerPageKey, pageKey]); return useMemo(() => ( `; -exports[`should not update layout if page is not the active Component of LayoutTree 1`] = ` +exports[`should ignore setLayoutState calls if Component's pageKey is not the active pageKey of LayoutTree 1`] = ` + + +
+ + +

+ Foo +

+
+
+
+
+ + +

+ Foo +

+
+
+
+`; + +exports[`should ignore setLayoutState calls if page is not the active Component of LayoutTree 1`] = ` - +
- +

Home

@@ -67,7 +115,7 @@ exports[`should not update layout if page is not the active Component of LayoutT setLayoutState={[Function]} >

- Home + Foo

@@ -250,3 +298,119 @@ exports[`should render no layout 1`] = `
`; + +exports[`should update the layout tree correctly if Component changes 1`] = ` + + +
+ + +

+ Home +

+
+
+
+
+
+`; + +exports[`should update the layout tree correctly if Component changes 2`] = ` + + +
+ + +

+ Foo +

+
+
+
+
+
+`; + +exports[`should update the layout tree correctly if pageKey changes 1`] = ` + + +
+ + +

+ Home +

+
+
+
+
+
+`; + +exports[`should update the layout tree correctly if pageKey changes 2`] = ` + + +
+ + +

+ Home +

+
+
+
+
+
+`; diff --git a/test/index.test.js b/test/index.test.js index da04721..232bb4b 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -3,7 +3,6 @@ import { mount } from 'enzyme'; import { withLayout, LayoutTree } from '../src'; const PrimaryLayout = ({ children }) =>
{ children }
; -const SecondaryLayout = ({ children }) =>
{ children }
; const Home = () =>

Home

; afterEach(() => { @@ -77,7 +76,6 @@ it('should call layout\'s and page\'s render just once', () => { it('should render a layout tree correctly based the initial layout state', () => { const mapLayoutStateToLayoutTree = jest.fn(({ variant }) => ); - const EnhancedHome = withLayout(mapLayoutStateToLayoutTree, { variant: 'light' })(Home); const wrapper = mount(); @@ -106,7 +104,7 @@ it('should render a layout tree correctly based the initial layout state (functi expect(mapPropsToInitialLayoutState).toHaveBeenLastCalledWith({ light: true }); }); -it('should update the layout tree correctly if setLayoutState is called', async () => { +it('should update the layout tree correctly if setLayoutState is called', () => { const Home = ({ setLayoutState }) => { useEffect(() => { setLayoutState({ variant: 'dark' }); @@ -119,20 +117,57 @@ it('should update the layout tree correctly if setLayoutState is called', async const HomeMock = jest.fn(Home); const mapLayoutStateToLayoutTree = jest.fn(({ variant }) => ); - const EnhancedHomeMock = withLayout(mapLayoutStateToLayoutTree, { variant: 'light' })(HomeMock); mount(); - await new Promise((resolve) => setTimeout(resolve, 50)); - expect(PrimaryLayoutMock).toHaveBeenCalledTimes(2); expect(PrimaryLayoutMock).toHaveBeenNthCalledWith(1, { children: expect.anything(), variant: 'light' }, {}); expect(PrimaryLayoutMock).toHaveBeenNthCalledWith(2, { children: expect.anything(), variant: 'dark' }, {}); expect(HomeMock).toHaveBeenCalledTimes(2); }); -it('should update the layout tree correctly if setLayoutState is called (function)', async () => { +it('should update the layout tree correctly if Component changes', () => { + const Foo = () =>

Foo

; + + const EnhancedHome = withLayout()(Home); + const EnhancedFoo = withLayout()(Foo); + + const wrapper = mount(); + + expect(wrapper).toMatchSnapshot(); + + wrapper.setProps({ Component: EnhancedFoo }); + + expect(wrapper).toMatchSnapshot(); +}); + +it('should update the layout tree correctly if pageKey changes', () => { + let doSetLayoutState = true; + + const Home = ({ setLayoutState }) => { + useEffect(() => { + doSetLayoutState && setLayoutState({ variant: 'dark' }); + }, [setLayoutState]); + + return

Home

; + }; + + const mapLayoutStateToLayoutTree = jest.fn(({ variant }) => ); + const EnhancedHome = withLayout(mapLayoutStateToLayoutTree, { variant: 'light' })(Home); + + const wrapper = mount(); + + expect(wrapper).toMatchSnapshot(); + + doSetLayoutState = false; + wrapper.setProps({ pageKey: 'bar' }); + + expect(wrapper).toMatchSnapshot(); + expect(mapLayoutStateToLayoutTree).toHaveBeenCalledTimes(3); +}); + +it('should update the layout tree correctly if setLayoutState is called (function)', () => { expect.assertions(5); const Home = ({ setLayoutState }) => { @@ -151,13 +186,10 @@ it('should update the layout tree correctly if setLayoutState is called (functio const HomeMock = jest.fn(Home); const mapLayoutStateToLayoutTree = jest.fn(({ variant }) => ); - const EnhancedHomeMock = withLayout(mapLayoutStateToLayoutTree, { variant: 'light' })(HomeMock); mount(); - await new Promise((resolve) => setTimeout(resolve, 50)); - expect(PrimaryLayoutMock).toHaveBeenCalledTimes(2); expect(PrimaryLayoutMock).toHaveBeenNthCalledWith(1, { children: expect.anything(), variant: 'light' }, {}); expect(PrimaryLayoutMock).toHaveBeenNthCalledWith(2, { children: expect.anything(), variant: 'dark' }, {}); @@ -222,7 +254,7 @@ it('should fail if LayoutTree was not rendered', () => { }).toThrow(/it seems you forgot to include/i); }); -it('should not update layout if page is not the active Component of LayoutTree', () => { +it('should ignore setLayoutState calls if page is not the active Component of LayoutTree', () => { jest.spyOn(console, 'error').mockImplementation(() => {}); const Foo = ({ setLayoutState }) => { @@ -230,11 +262,12 @@ it('should not update layout if page is not the active Component of LayoutTree', setLayoutState({ variant: 'dark' }); }, [setLayoutState]); - return

Home

; + return

Foo

; }; - const EnhancedHome = withLayout()(Home); - const EnhancedFoo = withLayout(() => )(Foo); + const mapLayoutStateToLayoutTree = jest.fn(({ variant }) => ); + const EnhancedHome = withLayout(mapLayoutStateToLayoutTree, { variant: 'light' })(Home); + const EnhancedFoo = withLayout(mapLayoutStateToLayoutTree, { variant: 'light' })(Foo); const render = jest.fn((tree) => ( <> @@ -250,4 +283,36 @@ it('should not update layout if page is not the active Component of LayoutTree', ); expect(wrapper).toMatchSnapshot(); + expect(mapLayoutStateToLayoutTree).toHaveBeenCalledTimes(1); +}); + +it('should ignore setLayoutState calls if Component\'s pageKey is not the active pageKey of LayoutTree', () => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + + const Foo = ({ foo, setLayoutState }) => { + useEffect(() => { + foo && setLayoutState({ variant: 'dark' }); + }, [foo, setLayoutState]); + + return

Foo

; + }; + + const mapLayoutStateToLayoutTree = jest.fn(({ variant }) => ); + const EnhancedFoo = withLayout(mapLayoutStateToLayoutTree, { variant: 'light' })(Foo); + + const render = jest.fn((tree) => ( + <> + { tree } + { } + + )); + + const wrapper = mount( + + { render } + , + ); + + expect(wrapper).toMatchSnapshot(); + expect(mapLayoutStateToLayoutTree).toHaveBeenCalledTimes(1); });