diff --git a/src/core/public/application/integration_tests/router.test.tsx b/src/core/public/application/integration_tests/router.test.tsx index ffc10820a9c37a..10544c348afb03 100644 --- a/src/core/public/application/integration_tests/router.test.tsx +++ b/src/core/public/application/integration_tests/router.test.tsx @@ -18,7 +18,7 @@ */ import React from 'react'; -import { createMemoryHistory, History } from 'history'; +import { createMemoryHistory, History, createHashHistory } from 'history'; import { AppRouter, AppNotFound } from '../ui'; import { EitherApp, MockedMounterMap, MockedMounterTuple } from '../test_types'; @@ -27,7 +27,15 @@ import { createRenderer, createAppMounter, createLegacyAppMounter } from './util describe('AppContainer', () => { let mounters: MockedMounterMap; let history: History; - let navigate: ReturnType; + let update: ReturnType; + + const navigate = (path: string) => { + history.push(path); + return update(); + }; + + const mockMountersToMounters = () => + new Map([...mounters].map(([appId, { mounter }]) => [appId, mounter])); beforeEach(() => { mounters = new Map([ @@ -38,14 +46,14 @@ describe('AppContainer', () => { createAppMounter('app3', '
App 3
', '/custom/path'), ] as Array>); history = createMemoryHistory(); - navigate = createRenderer(, history.push); + update = createRenderer(); }); it('calls mount handler and returned unmount function when navigating between apps', async () => { const dom1 = await navigate('/app/app1'); const app1 = mounters.get('app1')!; - expect(app1.mount).toHaveBeenCalled(); + expect(app1.mounter.mount).toHaveBeenCalled(); expect(dom1?.html()).toMatchInlineSnapshot(` "
basename: /app/app1 @@ -53,11 +61,11 @@ describe('AppContainer', () => {
" `); - const app1Unmount = await app1.mount.mock.results[0].value; + const app1Unmount = await app1.mounter.mount.mock.results[0].value; const dom2 = await navigate('/app/app2'); expect(app1Unmount).toHaveBeenCalled(); - expect(mounters.get('app2')!.mount).toHaveBeenCalled(); + expect(mounters.get('app2')!.mounter.mount).toHaveBeenCalled(); expect(dom2?.html()).toMatchInlineSnapshot(` "
basename: /app/app2 @@ -70,29 +78,77 @@ describe('AppContainer', () => { mounters.set(...createAppMounter('spaces', '
Custom Space
', '/spaces/fake-login')); mounters.set(...createAppMounter('login', '
Login Page
', '/fake-login')); history = createMemoryHistory(); - navigate = createRenderer(, history.push); + update = createRenderer(); await navigate('/fake-login'); - expect(mounters.get('spaces')!.mount).not.toHaveBeenCalled(); - expect(mounters.get('login')!.mount).toHaveBeenCalled(); + expect(mounters.get('spaces')!.mounter.mount).not.toHaveBeenCalled(); + expect(mounters.get('login')!.mounter.mount).toHaveBeenCalled(); }); it('should not mount when partial route path has higher specificity', async () => { mounters.set(...createAppMounter('login', '
Login Page
', '/fake-login')); mounters.set(...createAppMounter('spaces', '
Custom Space
', '/spaces/fake-login')); history = createMemoryHistory(); - navigate = createRenderer(, history.push); + update = createRenderer(); await navigate('/spaces/fake-login'); - expect(mounters.get('spaces')!.mount).toHaveBeenCalled(); - expect(mounters.get('login')!.mount).not.toHaveBeenCalled(); + expect(mounters.get('spaces')!.mounter.mount).toHaveBeenCalled(); + expect(mounters.get('login')!.mounter.mount).not.toHaveBeenCalled(); + }); + + it('should not remount when changing pages within app', async () => { + const { mounter, unmount } = mounters.get('app1')!; + await navigate('/app/app1/page1'); + expect(mounter.mount).toHaveBeenCalledTimes(1); + + // Navigating to page within app does not trigger re-render + await navigate('/app/app1/page2'); + expect(mounter.mount).toHaveBeenCalledTimes(1); + expect(unmount).not.toHaveBeenCalled(); + }); + + it('should not remount when going back within app', async () => { + const { mounter, unmount } = mounters.get('app1')!; + await navigate('/app/app1/page1'); + expect(mounter.mount).toHaveBeenCalledTimes(1); + + // Hitting back button within app does not trigger re-render + await navigate('/app/app1/page2'); + history.goBack(); + await update(); + expect(mounter.mount).toHaveBeenCalledTimes(1); + expect(unmount).not.toHaveBeenCalled(); + }); + + it('should not remount when when changing pages within app using hash history', async () => { + history = createHashHistory(); + update = createRenderer(); + + const { mounter, unmount } = mounters.get('app1')!; + await navigate('/app/app1/page1'); + expect(mounter.mount).toHaveBeenCalledTimes(1); + + // Changing hash history does not trigger re-render + await navigate('/app/app1/page2'); + expect(mounter.mount).toHaveBeenCalledTimes(1); + expect(unmount).not.toHaveBeenCalled(); + }); + + it('should unmount when changing between apps', async () => { + const { mounter, unmount } = mounters.get('app1')!; + await navigate('/app/app1/page1'); + expect(mounter.mount).toHaveBeenCalledTimes(1); + + // Navigating to other app triggers unmount + await navigate('/app/app2/page1'); + expect(unmount).toHaveBeenCalledTimes(1); }); it('calls legacy mount handler', async () => { await navigate('/app/legacyApp1'); - expect(mounters.get('legacyApp1')!.mount.mock.calls[0]).toMatchInlineSnapshot(` + expect(mounters.get('legacyApp1')!.mounter.mount.mock.calls[0]).toMatchInlineSnapshot(` Array [ Object { "appBasePath": "/app/legacyApp1", @@ -104,7 +160,7 @@ describe('AppContainer', () => { it('handles legacy apps with subapps', async () => { await navigate('/app/baseApp'); - expect(mounters.get('baseApp:legacyApp2')!.mount.mock.calls[0]).toMatchInlineSnapshot(` + expect(mounters.get('baseApp:legacyApp2')!.mounter.mount.mock.calls[0]).toMatchInlineSnapshot(` Array [ Object { "appBasePath": "/app/baseApp", diff --git a/src/core/public/application/integration_tests/utils.tsx b/src/core/public/application/integration_tests/utils.tsx index b8ade4d1d87870..6367d1fa12697e 100644 --- a/src/core/public/application/integration_tests/utils.tsx +++ b/src/core/public/application/integration_tests/utils.tsx @@ -26,19 +26,13 @@ import { App, LegacyApp, AppMountParameters } from '../types'; import { MockedMounter, MockedMounterTuple } from '../test_types'; type Dom = ReturnType | null; -type Renderer = (item: string) => Dom | Promise; +type Renderer = () => Dom | Promise; -export const createRenderer = ( - element: ReactElement | null, - callback?: (item: string) => void | Promise -): Renderer => { +export const createRenderer = (element: ReactElement | null): Renderer => { const dom: Dom = element && mount({element}); - return item => + return () => new Promise(async resolve => { - if (callback) { - await callback(item); - } if (dom) { dom.update(); } @@ -50,19 +44,26 @@ export const createAppMounter = ( appId: string, html: string, appRoute = `/app/${appId}` -): MockedMounterTuple => [ - appId, - { - appRoute, - appBasePath: appRoute, - mount: jest.fn(async ({ appBasePath: basename, element }: AppMountParameters) => { - Object.assign(element, { - innerHTML: `
\nbasename: ${basename}\nhtml: ${html}\n
`, - }); - return jest.fn(() => Object.assign(element, { innerHTML: '' })); - }), - }, -]; +): MockedMounterTuple => { + const unmount = jest.fn(); + return [ + appId, + { + mounter: { + appRoute, + appBasePath: appRoute, + mount: jest.fn(async ({ appBasePath: basename, element }: AppMountParameters) => { + Object.assign(element, { + innerHTML: `
\nbasename: ${basename}\nhtml: ${html}\n
`, + }); + unmount.mockImplementation(() => Object.assign(element, { innerHTML: '' })); + return unmount; + }), + }, + unmount, + }, + ]; +}; export const createLegacyAppMounter = ( appId: string, @@ -70,9 +71,12 @@ export const createLegacyAppMounter = ( ): MockedMounterTuple => [ appId, { - appRoute: `/app/${appId.split(':')[0]}`, - appBasePath: `/app/${appId.split(':')[0]}`, - unmountBeforeMounting: true, - mount: legacyMount, + mounter: { + appRoute: `/app/${appId.split(':')[0]}`, + appBasePath: `/app/${appId.split(':')[0]}`, + unmountBeforeMounting: true, + mount: legacyMount, + }, + unmount: jest.fn(), }, ]; diff --git a/src/core/public/application/test_types.ts b/src/core/public/application/test_types.ts index f5fb639eaa32cf..3d992cb950eb42 100644 --- a/src/core/public/application/test_types.ts +++ b/src/core/public/application/test_types.ts @@ -17,7 +17,7 @@ * under the License. */ -import { App, LegacyApp, Mounter } from './types'; +import { App, LegacyApp, Mounter, AppUnmount } from './types'; import { ApplicationService } from './application_service'; /** @internal */ @@ -25,11 +25,19 @@ export type ApplicationServiceContract = PublicMethodsOf; /** @internal */ export type EitherApp = App | LegacyApp; /** @internal */ +export type MockedUnmount = jest.Mocked; +/** @internal */ export type MockedMounter = jest.Mocked>>; /** @internal */ -export type MockedMounterTuple = [string, MockedMounter]; +export type MockedMounterTuple = [ + string, + { mounter: MockedMounter; unmount: MockedUnmount } +]; /** @internal */ -export type MockedMounterMap = Map>; +export type MockedMounterMap = Map< + string, + { mounter: MockedMounter; unmount: MockedUnmount } +>; /** @internal */ export type MockLifecycle< T extends keyof ApplicationService, diff --git a/src/core/public/application/ui/app_container.tsx b/src/core/public/application/ui/app_container.tsx index 96ee91c7c21fb5..153582e805fa1b 100644 --- a/src/core/public/application/ui/app_container.tsx +++ b/src/core/public/application/ui/app_container.tsx @@ -65,7 +65,7 @@ export const AppContainer: FunctionComponent = ({ mounter, appId }: Props mount(); return unmount; - }); + }, [mounter]); return ( diff --git a/src/core/server/rendering/views/styles.tsx b/src/core/server/rendering/views/styles.tsx index 40261321dcffcb..f41627bcfe07ff 100644 --- a/src/core/server/rendering/views/styles.tsx +++ b/src/core/server/rendering/views/styles.tsx @@ -78,7 +78,7 @@ export const Styles: FunctionComponent = ({ darkMode }) => { background-repeat: no-repeat; background-size: contain; /* SVG optimized according to http://codepen.io/tigt/post/optimizing-svgs-in-data-uris */ - background-image: url'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMCIgaGVpZ2h0PSIzOSIgdmlld0JveD0iMCAwIDMwIDM5Ij4gIDxnIGZpbGw9Im5vbmUiIGZpbGwtcnVsZT0iZXZlbm9kZCI+ICAgIDxwb2x5Z29uIGZpbGw9IiNGMDRFOTgiIHBvaW50cz0iMCAwIDAgMzQuNTQ3IDI5LjkyMiAuMDIiLz4gICAgPHBhdGggZmlsbD0iIzM0Mzc0MSIgZD0iTTAsMTQuNCBMMCwzNC41NDY4IEwxNC4yODcyLDE4LjA2MTIgQzEwLjA0MTYsMTUuNzM4IDUuMTgwNCwxNC40IDAsMTQuNCIvPiAgICA8cGF0aCBmaWxsPSIjMDBCRkIzIiBkPSJNMTcuMzc0MiwxOS45OTY4IEwyLjcyMSwzNi45MDQ4IEwxLjQzMzQsMzguMzg5MiBMMjkuMjYzOCwzOC4zODkyIEMyNy43NjE0LDMwLjgzODggMjMuNDA0MiwyNC4zMjY0IDE3LjM3NDIsMTkuOTk2OCIvPiAgPC9nPjwvc3ZnPg=='); + background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMCIgaGVpZ2h0PSIzOSIgdmlld0JveD0iMCAwIDMwIDM5Ij4gIDxnIGZpbGw9Im5vbmUiIGZpbGwtcnVsZT0iZXZlbm9kZCI+ICAgIDxwb2x5Z29uIGZpbGw9IiNGMDRFOTgiIHBvaW50cz0iMCAwIDAgMzQuNTQ3IDI5LjkyMiAuMDIiLz4gICAgPHBhdGggZmlsbD0iIzM0Mzc0MSIgZD0iTTAsMTQuNCBMMCwzNC41NDY4IEwxNC4yODcyLDE4LjA2MTIgQzEwLjA0MTYsMTUuNzM4IDUuMTgwNCwxNC40IDAsMTQuNCIvPiAgICA8cGF0aCBmaWxsPSIjMDBCRkIzIiBkPSJNMTcuMzc0MiwxOS45OTY4IEwyLjcyMSwzNi45MDQ4IEwxLjQzMzQsMzguMzg5MiBMMjkuMjYzOCwzOC4zODkyIEMyNy43NjE0LDMwLjgzODggMjMuNDA0MiwyNC4zMjY0IDE3LjM3NDIsMTkuOTk2OCIvPiAgPC9nPjwvc3ZnPg=='); } .kibanaWelcomeTitle {