From adb4a5a4f80d31f2eb28b6859687730f74af84a4 Mon Sep 17 00:00:00 2001 From: Repraance Date: Mon, 18 Dec 2023 20:48:50 +0800 Subject: [PATCH] feat: add router basename config all --- .../src/shared/helper/getAppData.ts | 1 + .../features/html-render/lib/renderToHTML.ts | 11 +- .../features/html-render/lib/renderer/spa.ts | 6 +- .../features/html-render/lib/renderer/ssr.ts | 3 +- .../onDemandRouteManager.ts | 11 +- packages/platform-web/src/shared/appTypes.ts | 6 +- .../platform-web/src/shuvi-app/app/client.ts | 6 +- .../platform-web/src/shuvi-app/app/server.ts | 5 +- .../shuvi-app/react/view/ReactView.server.tsx | 2 +- packages/router-react/src/MemoryRouter.tsx | 3 +- .../src/__tests__/useHref-basename.test.js | 16 +- .../src/__tests__/useHref.test.js | 100 ++++++ .../router/src/__tests__/matchRoutes.test.ts | 58 +--- packages/router/src/__tests__/router.test.ts | 144 +++++---- .../src/history/__tests__/browser.test.ts | 51 +++- .../src/history/__tests__/hash-base.test.ts | 10 +- .../router/src/history/__tests__/hash.test.ts | 27 +- .../src/history/__tests__/memory.test.ts | 43 ++- packages/router/src/history/base.ts | 23 +- packages/router/src/history/browser.ts | 35 ++- packages/router/src/history/hash.ts | 56 ++-- packages/router/src/history/index.ts | 16 +- packages/router/src/history/memory.ts | 40 +-- packages/router/src/matchRoutes.ts | 18 +- packages/router/src/router.ts | 37 ++- packages/router/src/types/history.ts | 4 + packages/router/src/types/router.ts | 1 + packages/router/src/utils/dom.ts | 1 - packages/router/src/utils/history.ts | 14 +- packages/router/src/utils/path.ts | 46 +-- .../middlewares/setupAppConfigMiddleware.ts | 19 ++ packages/service/src/server/plugin.ts | 28 +- packages/service/src/server/shuviDevServer.ts | 3 + .../service/src/server/shuviProdServer.ts | 2 + pnpm-lock.yaml | 6 + test/e2e/basename.test.ts | 95 ++++++ test/e2e/loader.test.ts | 287 ++++++++++-------- test/fixtures/basename/package.json | 6 + test/fixtures/basename/plugin/server.js | 15 + test/fixtures/basename/shuvi.config.js | 7 + .../basename/src/routes/about/page.js | 18 ++ test/fixtures/basename/src/routes/layout.js | 7 + .../fixtures/basename/src/routes/list/page.js | 13 + test/fixtures/basename/src/routes/page.js | 32 ++ test/fixtures/loader/plugin/server.js | 15 + test/fixtures/loader/shuvi.config.ts | 5 +- test/utils/launcher.ts | 2 + 47 files changed, 940 insertions(+), 414 deletions(-) delete mode 100644 packages/router/src/utils/dom.ts create mode 100644 packages/service/src/server/middlewares/setupAppConfigMiddleware.ts create mode 100644 test/e2e/basename.test.ts create mode 100644 test/fixtures/basename/package.json create mode 100644 test/fixtures/basename/plugin/server.js create mode 100644 test/fixtures/basename/shuvi.config.js create mode 100644 test/fixtures/basename/src/routes/about/page.js create mode 100644 test/fixtures/basename/src/routes/layout.js create mode 100644 test/fixtures/basename/src/routes/list/page.js create mode 100644 test/fixtures/basename/src/routes/page.js create mode 100644 test/fixtures/loader/plugin/server.js diff --git a/packages/platform-shared/src/shared/helper/getAppData.ts b/packages/platform-shared/src/shared/helper/getAppData.ts index 2d08e20a1..f9fd62b8b 100644 --- a/packages/platform-shared/src/shared/helper/getAppData.ts +++ b/packages/platform-shared/src/shared/helper/getAppData.ts @@ -7,6 +7,7 @@ export type IData = { export type IAppData = { ssr: boolean; + basename?: string; runtimeConfig?: Record; appState?: appState; pageData?: { diff --git a/packages/platform-web/src/node/features/html-render/lib/renderToHTML.ts b/packages/platform-web/src/node/features/html-render/lib/renderToHTML.ts index 30e228137..6e9531a9d 100644 --- a/packages/platform-web/src/node/features/html-render/lib/renderToHTML.ts +++ b/packages/platform-web/src/node/features/html-render/lib/renderToHTML.ts @@ -13,6 +13,12 @@ export async function renderToHTML({ }): Promise { let result: Response; const renderer = new Renderer({ serverPluginContext }); + const { + config: { ssr }, + appConfig: { + router: { basename } + } + } = serverPluginContext; const { serverCreateAppTrace } = req._traces; const { application } = resources.server; const app = serverCreateAppTrace @@ -23,7 +29,8 @@ export async function renderToHTML({ .traceFn(() => application.createApp({ req, - ssr: serverPluginContext.config.ssr + ssr, + basename }) ); @@ -37,7 +44,7 @@ export async function renderToHTML({ result = await renderer.renderView({ req, app: app.getPublicAPI(), - ssr: serverPluginContext.config.ssr + ssr }); } finally { await app.dispose(); diff --git a/packages/platform-web/src/node/features/html-render/lib/renderer/spa.ts b/packages/platform-web/src/node/features/html-render/lib/renderer/spa.ts index bd5d92153..ef3aee8f1 100644 --- a/packages/platform-web/src/node/features/html-render/lib/renderer/spa.ts +++ b/packages/platform-web/src/node/features/html-render/lib/renderer/spa.ts @@ -4,9 +4,13 @@ import { IRenderViewOptions, IHtmlDocument } from './types'; export class SpaRenderer extends BaseRenderer { renderDocument({ req, app }: IRenderViewOptions) { const assets = this._getMainAssetTags(req); + const { + router: { basename } + } = this._serverPluginContext.appConfig; const appData: AppData = { ssr: false, - pageData: {} + pageData: {}, + basename }; const document: IHtmlDocument = { htmlAttrs: {}, diff --git a/packages/platform-web/src/node/features/html-render/lib/renderer/ssr.ts b/packages/platform-web/src/node/features/html-render/lib/renderer/ssr.ts index f04f77aba..421a5531e 100644 --- a/packages/platform-web/src/node/features/html-render/lib/renderer/ssr.ts +++ b/packages/platform-web/src/node/features/html-render/lib/renderer/ssr.ts @@ -34,7 +34,8 @@ export class SsrRenderer extends BaseRenderer { ...result.appData, ssr: true, appState: store.getState(), - pageData + pageData, + basename: router.basename }; appData.runtimeConfig = getPublicRuntimeConfig() || {}; diff --git a/packages/platform-web/src/node/features/on-demand-compile-page/onDemandRouteManager.ts b/packages/platform-web/src/node/features/on-demand-compile-page/onDemandRouteManager.ts index 36b671408..aa86007e9 100644 --- a/packages/platform-web/src/node/features/on-demand-compile-page/onDemandRouteManager.ts +++ b/packages/platform-web/src/node/features/on-demand-compile-page/onDemandRouteManager.ts @@ -1,4 +1,5 @@ -import { matchRoutes } from '@shuvi/router'; +import { createLocation, matchRoutes } from '@shuvi/router'; +import { normalizeBase } from '@shuvi/router/lib/utils'; import resources from '@shuvi/service/lib/resources'; import { ShuviRequestHandler, IServerPluginContext } from '@shuvi/service'; import { DevMiddleware } from '@shuvi/service/lib/server/middlewares/dev'; @@ -85,8 +86,14 @@ export default class OnDemandRouteManager { } async ensureRoutes(pathname: string): Promise { + const { + router: { basename } + } = this._serverPluginContext.appConfig; + const resolvedPath = createLocation(pathname, { + basename: normalizeBase(basename) + }); const matchedRoutes = - matchRoutes(resources.server.pageRoutes, pathname) || []; + matchRoutes(resources.server.pageRoutes, resolvedPath) || []; const modulesToActivate = matchedRoutes .map(({ route: { __componentRawRequest__ } }) => __componentRawRequest__) diff --git a/packages/platform-web/src/shared/appTypes.ts b/packages/platform-web/src/shared/appTypes.ts index 593b36803..579d093d1 100644 --- a/packages/platform-web/src/shared/appTypes.ts +++ b/packages/platform-web/src/shared/appTypes.ts @@ -16,7 +16,11 @@ export type InternalApplication = _ApplicationImpl; export type Application = _Application; export interface CreateAppServer { - (options: { req: ShuviRequest; ssr: boolean }): InternalApplication; + (options: { + req: ShuviRequest; + ssr: boolean; + basename: string; + }): InternalApplication; } export interface CreateAppClient { diff --git a/packages/platform-web/src/shuvi-app/app/client.ts b/packages/platform-web/src/shuvi-app/app/client.ts index db1fbaf94..9cec5c4c3 100644 --- a/packages/platform-web/src/shuvi-app/app/client.ts +++ b/packages/platform-web/src/shuvi-app/app/client.ts @@ -38,12 +38,12 @@ export const createApp: CreateAppClient = ({ return app; } - const { appState, ssr } = appData; + const { appState, ssr, basename } = appData; let history: History; if (historyMode === 'hash') { - history = createHashHistory(); + history = createHashHistory({ basename }); } else { - history = createBrowserHistory(); + history = createBrowserHistory({ basename }); } const router = createRouter({ diff --git a/packages/platform-web/src/shuvi-app/app/server.ts b/packages/platform-web/src/shuvi-app/app/server.ts index 7f1659927..420842162 100644 --- a/packages/platform-web/src/shuvi-app/app/server.ts +++ b/packages/platform-web/src/shuvi-app/app/server.ts @@ -21,10 +21,11 @@ import { serializeServerError } from '../helper/serializeServerError'; const { SHUVI_SERVER_RUN_LOADERS } = SERVER_CREATE_APP.events; export const createApp: CreateAppServer = options => { - const { req, ssr } = options; + const { req, ssr, basename } = options; const history = createMemoryHistory({ initialEntries: [(req && req.url) || '/'], - initialIndex: 0 + initialIndex: 0, + basename }); const router = createRouter({ history, diff --git a/packages/platform-web/src/shuvi-app/react/view/ReactView.server.tsx b/packages/platform-web/src/shuvi-app/react/view/ReactView.server.tsx index 04c64c095..f99fdfe9f 100644 --- a/packages/platform-web/src/shuvi-app/react/view/ReactView.server.tsx +++ b/packages/platform-web/src/shuvi-app/react/view/ReactView.server.tsx @@ -47,7 +47,7 @@ export class ReactServerView implements IReactServerView { ); } // handle router internal redirect - return redirect(pathname); + return redirect(router.resolve(router.current).href); } const loadableModules: string[] = []; diff --git a/packages/router-react/src/MemoryRouter.tsx b/packages/router-react/src/MemoryRouter.tsx index e559abfe0..3cc006d7a 100644 --- a/packages/router-react/src/MemoryRouter.tsx +++ b/packages/router-react/src/MemoryRouter.tsx @@ -18,9 +18,8 @@ export function MemoryRouter({ let routerRef = React.useRef(); if (routerRef.current == null) { routerRef.current = createRouter({ - basename, routes: routes || [], - history: createMemoryHistory({ initialEntries, initialIndex }) + history: createMemoryHistory({ initialEntries, initialIndex, basename }) }).init(); } diff --git a/packages/router-react/src/__tests__/useHref-basename.test.js b/packages/router-react/src/__tests__/useHref-basename.test.js index a90437f9e..c66b53c15 100644 --- a/packages/router-react/src/__tests__/useHref-basename.test.js +++ b/packages/router-react/src/__tests__/useHref-basename.test.js @@ -241,7 +241,7 @@ describe('useHref under a ', () => { ); - expect(href).toBe('/app/'); + expect(href).toBe('/app'); }); }); }); @@ -250,7 +250,7 @@ describe('useHref under a ', () => { it('returns the correct href', () => { let href; function Admin() { - href = useHref('../../../dashboard'); + href = useHref('../../dashboard'); return

Admin

; } @@ -269,7 +269,7 @@ describe('useHref under a ', () => { ); - expect(href).toBe('/dashboard'); + expect(href).toBe('/app/dashboard'); }); describe('and no additional segments', () => { @@ -295,7 +295,7 @@ describe('useHref under a ', () => { ); - expect(href).toBe('/'); + expect(href).toBe('/app'); }); }); @@ -303,7 +303,7 @@ describe('useHref under a ', () => { it('returns the correct href', () => { let href; function Admin() { - href = useHref('../../../dashboard'); + href = useHref('../../dashboard'); return

Admin

; } @@ -322,7 +322,7 @@ describe('useHref under a ', () => { ); - expect(href).toBe('/dashboard'); + expect(href).toBe('/app/dashboard'); }); }); @@ -330,7 +330,7 @@ describe('useHref under a ', () => { it('returns the correct href', () => { let href; function Admin() { - href = useHref('../../../dashboard/'); + href = useHref('../../dashboard/'); return

Admin

; } @@ -349,7 +349,7 @@ describe('useHref under a ', () => { ); - expect(href).toBe('/dashboard/'); + expect(href).toBe('/app/dashboard/'); }); }); }); diff --git a/packages/router-react/src/__tests__/useHref.test.js b/packages/router-react/src/__tests__/useHref.test.js index 97630a0c3..b71d94de9 100644 --- a/packages/router-react/src/__tests__/useHref.test.js +++ b/packages/router-react/src/__tests__/useHref.test.js @@ -235,6 +235,56 @@ describe('useHref', () => { expect(href).toBe('/courses/'); }); }); + + describe('when up to root route, href should always be root /', () => { + it('when href has no trailing slash', () => { + let href; + function AdvancedReact() { + href = useHref('..'); + return

Advanced React

; + } + + createTestRenderer( + + + + ); + + expect(href).toBe('/'); + }); + + it('when href has a trailing slash', () => { + let href; + function AdvancedReact() { + href = useHref('../'); + return

Advanced React

; + } + + createTestRenderer( + + + + ); + + expect(href).toBe('/'); + }); + }); }); describe('with a to value that has more .. segments than are in the URL', () => { @@ -277,6 +327,56 @@ describe('useHref', () => { expect(href).toBe('/courses'); }); + describe('when up to root route, href should always be root /', () => { + it('when href has no trailing slash', () => { + let href; + function AdvancedReact() { + href = useHref('../..'); + return

Advanced React

; + } + + createTestRenderer( + + + + ); + + expect(href).toBe('/'); + }); + + it('when href has a trailing slash', () => { + let href; + function AdvancedReact() { + href = useHref('../../'); + return

Advanced React

; + } + + createTestRenderer( + + + + ); + + expect(href).toBe('/'); + }); + }); + describe('and no additional segments', () => { it('links to the root /', () => { let href; diff --git a/packages/router/src/__tests__/matchRoutes.test.ts b/packages/router/src/__tests__/matchRoutes.test.ts index c5c01ac39..918ee7691 100644 --- a/packages/router/src/__tests__/matchRoutes.test.ts +++ b/packages/router/src/__tests__/matchRoutes.test.ts @@ -123,7 +123,9 @@ describe('path matching', () => { expect(pickPaths(routes, '/groups/main')).toEqual(['/groups/main']); expect(pickPaths(routes, '/groups/123')).toEqual(['/groups/:groupId']); expect(pickPaths(routes, '/groups')).toEqual(['/groups']); - expect(pickPaths(routes, '/files/some/long/path')).toEqual(['/files/:_other(.*)']); + expect(pickPaths(routes, '/files/some/long/path')).toEqual([ + '/files/:_other(.*)' + ]); expect(pickPaths(routes, '/files')).toEqual(['/files']); expect(pickPaths(routes, '/one/two/three/four/five')).toEqual([ '/:one/:two/:three/:four/:five' @@ -194,57 +196,3 @@ describe('path matching', () => { expect(pickPaths(routes, '/page')).toEqual(['page']); }); }); - -describe('path matching with a basename', () => { - let routes = [ - { - path: '/users/:userId', - children: [ - { - path: 'subjects', - children: [ - { - path: ':courseId' - } - ] - } - ] - } - ]; - - test('top-level route', () => { - let location = { pathname: '/app/users/michael' }; - let matches = matchRoutes(routes, location, '/app'); - - expect(matches).not.toBeNull(); - expect(matches).toHaveLength(1); - expect(matches).toMatchObject([ - { - pathname: '/users/michael', - params: { userId: 'michael' } - } - ]); - }); - - test('deeply nested route', () => { - let location = { pathname: '/app/users/michael/subjects/react' }; - let matches = matchRoutes(routes, location, '/app'); - - expect(matches).not.toBeNull(); - expect(matches).toHaveLength(3); - expect(matches).toMatchObject([ - { - pathname: '/users/michael', - params: { userId: 'michael' } - }, - { - pathname: '/users/michael/subjects', - params: { userId: 'michael' } - }, - { - pathname: '/users/michael/subjects/react', - params: { userId: 'michael', courseId: 'react' } - } - ]); - }); -}); diff --git a/packages/router/src/__tests__/router.test.ts b/packages/router/src/__tests__/router.test.ts index f4e9998f1..45de61f07 100644 --- a/packages/router/src/__tests__/router.test.ts +++ b/packages/router/src/__tests__/router.test.ts @@ -2,54 +2,100 @@ import { MemoryHistory } from '../history'; import { createRouter } from '../router'; import { IRouter } from '../types'; -describe('router', () => { +describe.each([false, true])('router', hasBasename => { + let history: MemoryHistory; + beforeEach(() => { + if (hasBasename) { + history = new MemoryHistory({ + basename: '/base', + initialEntries: ['/base'] + }); + } else { + history = new MemoryHistory(); + } + }); describe('current', () => { - it('should not change until history changes', () => { + it('current should has correct value', () => { const router = createRouter({ - routes: [{ path: '/' }, { path: '/about' }], - history: new MemoryHistory({ - initialEntries: ['/', '/about'], - initialIndex: 0 - }) + routes: [{ path: '/:lng/about' }], + history: hasBasename + ? new MemoryHistory({ + initialEntries: ['/base/en/about?foo=foo&bar=bar#hash-1'], + basename: '/base' + }) + : new MemoryHistory({ + initialEntries: ['/en/about?foo=foo&bar=bar#hash-1'] + }) + }).init(); + + console.log(router.current); + + expect(router.current).toMatchObject({ + pathname: '/en/about', + matches: [ + { pathname: '/en/about', params: {}, route: { path: '/:lng/about' } } + ], + params: { + lng: 'en' + }, + query: { + foo: 'foo', + bar: 'bar' + }, + search: '?foo=foo&bar=bar', + hash: '#hash-1', + state: null, + redirected: false, + key: expect.any(String) }); + }); + + it('should not change until history changes', () => { + const router = createRouter({ + routes: [{ path: '/' }, { path: '/:lng/about' }], + history + }).init(); const { push, current } = router; expect(current).toEqual(router.current); - push('/about'); + console.log('======router', router.current); + expect(router.current).toMatchObject({ + pathname: '/', + matches: [{ pathname: '/', params: {}, route: { path: '/' } }], + params: {}, + query: {}, + search: '', + hash: '', + state: null, + redirected: false, + key: expect.any(String) + }); + push('/en/about?foo=foo&bar=bar#hash-1'); expect(current).not.toEqual(router.current); + expect(router.current).toMatchObject({ + pathname: '/en/about', + matches: [ + { pathname: '/en/about', params: {}, route: { path: '/:lng/about' } } + ], + params: { + lng: 'en' + }, + query: { + foo: 'foo', + bar: 'bar' + }, + search: '?foo=foo&bar=bar', + hash: '#hash-1', + state: null, + redirected: false, + key: expect.any(String) + }); }); }); describe('redirect', () => { - it('should have the correct current redirect', () => { - const router = createRouter({ - routes: [ - { path: '/' }, - { - path: 'about', - redirect: '/', - children: [ - { - path: 'redirect', - redirect: '/about' - } - ] - } - ], - history: new MemoryHistory({ - initialEntries: ['/about/redirect'], - initialIndex: 0 - }) - }).init(); - - let current = router.current; - expect(current.redirected).toBe(true); - expect(current.pathname).toBe('/'); - }); - describe('single redirect in route config', () => { it('should use replace when route config redirects at initial navigation', () => { - const history = new MemoryHistory(); jest.spyOn(history, 'replace'); jest.spyOn(history, 'push'); const router = createRouter({ @@ -70,7 +116,6 @@ describe('router', () => { }); it('should use push when route config redirects at push navigation', () => { - const history = new MemoryHistory(); jest.spyOn(history, 'replace'); jest.spyOn(history, 'push'); const router = createRouter({ @@ -110,8 +155,6 @@ describe('router', () => { }); it('should use replace when route config redirects at replace navigation', () => { - const history = new MemoryHistory(); - const router = createRouter({ routes: [ { path: '/' }, @@ -156,7 +199,6 @@ describe('router', () => { describe('multiple redirect in route config', () => { it('should use replace when route config redirects for multiple times at initial navigation', () => { - const history = new MemoryHistory(); jest.spyOn(history, 'replace'); jest.spyOn(history, 'push'); const router = createRouter({ @@ -190,7 +232,6 @@ describe('router', () => { }); it('should use push when route config redirects for multiple times at push navigation', () => { - const history = new MemoryHistory(); jest.spyOn(history, 'replace'); jest.spyOn(history, 'push'); const router = createRouter({ @@ -236,8 +277,6 @@ describe('router', () => { }); it('should use replace when route config redirects for multiple times at replace navigation', () => { - const history = new MemoryHistory(); - const router = createRouter({ routes: [ { path: '/' }, @@ -287,7 +326,6 @@ describe('router', () => { describe('single redirect in guards', () => { it('should use replace when a guard redirects at initial navigation', () => { - const history = new MemoryHistory(); jest.spyOn(history, 'replace'); jest.spyOn(history, 'push'); const router = createRouter({ @@ -317,7 +355,6 @@ describe('router', () => { }); it('should use push when a guard redirects at push navigation', () => { - const history = new MemoryHistory(); jest.spyOn(history, 'replace'); jest.spyOn(history, 'push'); const router = createRouter({ @@ -360,8 +397,6 @@ describe('router', () => { }); it('should use replace when a guard redirects with replace option at push navigation', () => { - const history = new MemoryHistory(); - const router = createRouter({ routes: [], history @@ -400,7 +435,6 @@ describe('router', () => { }); it('should use replace when a guard redirects at replace navigation', () => { - const history = new MemoryHistory(); const router = createRouter({ routes: [], history @@ -446,7 +480,6 @@ describe('router', () => { describe('multiple redirects in guards', () => { it('should use replace when guards redirect for multiple times at initial navigation', () => { - const history = new MemoryHistory(); jest.spyOn(history, 'replace'); jest.spyOn(history, 'push'); const router = createRouter({ @@ -489,7 +522,6 @@ describe('router', () => { }); it('should use push when guards redirect for multiple times at push navigation', () => { - const history = new MemoryHistory(); jest.spyOn(history, 'replace'); jest.spyOn(history, 'push'); const router = createRouter({ @@ -541,8 +573,6 @@ describe('router', () => { }); it('should use replace when guards redirect with replace option for multiple times at push navigation', () => { - const history = new MemoryHistory(); - const router = createRouter({ routes: [], history @@ -615,7 +645,6 @@ describe('router', () => { }); it('should use replace when guards redirect for multiple times at replace navigation', () => { - const history = new MemoryHistory(); const router = createRouter({ routes: [], history @@ -669,7 +698,6 @@ describe('router', () => { describe('redirect in both guards and route config', () => { it('should use replace when route config and guards redirect at initial navigation', () => { - const history = new MemoryHistory(); jest.spyOn(history, 'replace'); jest.spyOn(history, 'push'); const router = createRouter({ @@ -712,7 +740,6 @@ describe('router', () => { }); it('should use push when route config and guards redirect at push navigation', () => { - const history = new MemoryHistory(); jest.spyOn(history, 'replace'); jest.spyOn(history, 'push'); const router = createRouter({ @@ -771,8 +798,6 @@ describe('router', () => { }); it('should use replace when route config and guards redirect with replace option for multiple times at push navigation', () => { - const history = new MemoryHistory(); - const router = createRouter({ routes: [ { path: '/' }, @@ -842,8 +867,6 @@ describe('router', () => { }); it('should use replace when route config and guards redirect at replace navigation', () => { - const history = new MemoryHistory(); - const router = createRouter({ routes: [ { path: '/' }, @@ -943,10 +966,7 @@ describe('router', () => { { path: '/new' }, { path: '/redirectToNew', redirect: '/new' } ], - history: new MemoryHistory({ - initialEntries: ['/', '/about'], - initialIndex: 0 - }) + history }).init(); }); diff --git a/packages/router/src/history/__tests__/browser.test.ts b/packages/router/src/history/__tests__/browser.test.ts index ff9302cde..fd1db578a 100644 --- a/packages/router/src/history/__tests__/browser.test.ts +++ b/packages/router/src/history/__tests__/browser.test.ts @@ -21,12 +21,15 @@ import BlockPopWithoutListening from './TestSequences/BlockPopWithoutListening.j import { createRouter } from '../../router'; import { IRouter, IRouteRecord } from '../../types'; -describe('a browser history', () => { +describe.each([false, true])('a browser history', hasBasename => { let router: IRouter; beforeEach(() => { + const initialUrl = hasBasename ? '/base/' : '/'; // @ts-ignore - window.history.replaceState(null, null, '/'); - let history = createBrowserHistory(); + window.history.replaceState(null, null, initialUrl); + let history = hasBasename + ? createBrowserHistory({ basename: '/base' }) + : createBrowserHistory(); router = createRouter({ routes: [] as IRouteRecord[], history }).init(); }); @@ -37,24 +40,37 @@ describe('a browser history', () => { hash: '#the-hash' }); - expect(href).toEqual('/the/path?the=query#the-hash'); + const expectedHref = hasBasename + ? '/base/the/path?the=query#the-hash' + : '/the/path?the=query#the-hash'; + + expect(href).toEqual(expectedHref); }); it('knows how to create hrefs from strings', () => { const { href } = router.resolve('/the/path?the=query#the-hash'); - expect(href).toEqual('/the/path?the=query#the-hash'); + + const expectedHref = hasBasename + ? '/base/the/path?the=query#the-hash' + : '/the/path?the=query#the-hash'; + expect(href).toEqual(expectedHref); }); it('does not encode the generated path', () => { const { href: encodedHref } = router.resolve({ pathname: '/%23abc' }); - expect(encodedHref).toEqual('/%23abc'); + + const expectedHref = hasBasename ? '/base/%23abc' : '/%23abc'; + + expect(encodedHref).toEqual(expectedHref); const { href: unencodedHref } = router.resolve({ pathname: '/#abc' }); - expect(unencodedHref).toEqual('/#abc'); + + const expectedUnencodedHref = hasBasename ? '/base/#abc' : '/#abc'; + expect(unencodedHref).toEqual(expectedUnencodedHref); }); describe('the initial location', () => { @@ -83,6 +99,7 @@ describe('a browser history', () => { describe('push with no pathname', () => { it('reuses the current location pathname', done => { + console.log('--------router current', router.current); PushMissingPathname(router, done); }); }); @@ -141,3 +158,23 @@ describe('a browser history', () => { }); }); }); + +describe('init with basename', () => { + it('should redirect if initial entry does not match base', () => { + // @ts-ignore + window.history.replaceState(null, null, '/'); + + const history = createBrowserHistory({ basename: '/base' }); + jest.spyOn(history, 'replace'); + jest.spyOn(history, 'push'); + const router = createRouter({ + routes: [], + history + }).init(); + + let current = router.current; + + expect(router.resolve('/').href).toBe('/base'); + expect(current.pathname).toBe('/'); + }); +}); diff --git a/packages/router/src/history/__tests__/hash-base.test.ts b/packages/router/src/history/__tests__/hash-base.test.ts index 05d50817c..44fd53e60 100644 --- a/packages/router/src/history/__tests__/hash-base.test.ts +++ b/packages/router/src/history/__tests__/hash-base.test.ts @@ -29,19 +29,13 @@ describe('a hash history on a page with a tag', () => { document.head.removeChild(base); }); - it('knows how to create hrefs', () => { - const hashIndex = window.location.href.indexOf('#'); - const upToHash = - hashIndex === -1 - ? window.location.href - : window.location.href.slice(0, hashIndex); - + it('should ignore base tag', () => { const { href } = router.resolve({ pathname: '/the/path', search: '?the=query', hash: '#the-hash' }); - expect(href).toEqual(upToHash + '#/the/path?the=query#the-hash'); + expect(href).toEqual('#/the/path?the=query#the-hash'); }); }); diff --git a/packages/router/src/history/__tests__/hash.test.ts b/packages/router/src/history/__tests__/hash.test.ts index 1deb4cccf..2dfe65e7a 100644 --- a/packages/router/src/history/__tests__/hash.test.ts +++ b/packages/router/src/history/__tests__/hash.test.ts @@ -21,12 +21,16 @@ import BlockPopWithoutListening from './TestSequences/BlockPopWithoutListening.j import { IRouter, IRouteRecord } from '../../types'; import { createRouter } from '../../router'; -describe('a hash history', () => { +describe.each([false, true])('a hash history', hasBasename => { let router: IRouter; beforeEach(() => { + const initialUrl = hasBasename ? '#/base' : '#/'; // @ts-ignore - window.history.replaceState(null, null, '#/'); - let history = createHashHistory(); + window.history.replaceState(null, null, initialUrl); + let history = hasBasename + ? createHashHistory({ basename: '/base' }) + : createHashHistory(); + router = createRouter({ routes: [] as IRouteRecord[], history @@ -39,25 +43,32 @@ describe('a hash history', () => { search: '?the=query', hash: '#the-hash' }); - - expect(href).toEqual('#/the/path?the=query#the-hash'); + const expectedHref = hasBasename + ? '#/base/the/path?the=query#the-hash' + : '#/the/path?the=query#the-hash'; + expect(href).toEqual(expectedHref); }); it('knows how to create hrefs from strings', () => { const { href } = router.resolve('/the/path?the=query#the-hash'); - expect(href).toEqual('#/the/path?the=query#the-hash'); + const expectedHref = hasBasename + ? '#/base/the/path?the=query#the-hash' + : '#/the/path?the=query#the-hash'; + expect(href).toEqual(expectedHref); }); it('does not encode the generated path', () => { const { href: encodedHref } = router.resolve({ pathname: '/%23abc' }); - expect(encodedHref).toEqual('#/%23abc'); + const expectedHref = hasBasename ? '#/base/%23abc' : '#/%23abc'; + expect(encodedHref).toEqual(expectedHref); const { href: unencodedHref } = router.resolve({ pathname: '/#abc' }); - expect(unencodedHref).toEqual('#/#abc'); + const expectedUnencodedHref = hasBasename ? '#/base/#abc' : '#/#abc'; + expect(unencodedHref).toEqual(expectedUnencodedHref); }); describe('the initial location', () => { diff --git a/packages/router/src/history/__tests__/memory.test.ts b/packages/router/src/history/__tests__/memory.test.ts index 1d7fdf336..8e8cb2dfa 100644 --- a/packages/router/src/history/__tests__/memory.test.ts +++ b/packages/router/src/history/__tests__/memory.test.ts @@ -17,10 +17,12 @@ import BlockPopWithoutListening from './TestSequences/BlockPopWithoutListening.j import { createRouter } from '../../router'; import { IRouter, IRouteRecord } from '../../types'; -describe('a memory history', () => { +describe.each([false, true])('a memory history', hasBasename => { let router: IRouter; beforeEach(() => { - let history = createMemoryHistory(); + let history = hasBasename + ? createMemoryHistory({ basename: '/base', initialEntries: ['/base'] }) + : createMemoryHistory(); router = createRouter({ routes: [] as IRouteRecord[], history @@ -33,25 +35,32 @@ describe('a memory history', () => { search: '?the=query', hash: '#the-hash' }); - - expect(href).toEqual('/the/path?the=query#the-hash'); + const expectedHref = hasBasename + ? '/base/the/path?the=query#the-hash' + : '/the/path?the=query#the-hash'; + expect(href).toEqual(expectedHref); }); it('knows how to create hrefs from strings', () => { const { href } = router.resolve('/the/path?the=query#the-hash'); - expect(href).toEqual('/the/path?the=query#the-hash'); + const expectedHref = hasBasename + ? '/base/the/path?the=query#the-hash' + : '/the/path?the=query#the-hash'; + expect(href).toEqual(expectedHref); }); it('does not encode the generated path', () => { const { href: encodedHref } = router.resolve({ pathname: '/%23abc' }); - expect(encodedHref).toEqual('/%23abc'); + const expectedHref = hasBasename ? '/base/%23abc' : '/%23abc'; + expect(encodedHref).toEqual(expectedHref); const { href: unencodedHref } = router.resolve({ pathname: '/#abc' }); - expect(unencodedHref).toEqual('/#abc'); + const expectedUnencodedHref = hasBasename ? '/base/#abc' : '/#abc'; + expect(unencodedHref).toEqual(expectedUnencodedHref); }); describe('the initial location', () => { @@ -167,3 +176,23 @@ describe('a memory history with some initial entries', () => { }); }); }); + +describe('init with basename', () => { + it('should redirect if initial entry does not match base', () => { + const history = createMemoryHistory({ + basename: '/base', + initialEntries: ['/does-not-match-base'] + }); + const router = createRouter({ + routes: [], + history + }).init(); + + let current = router.current; + + // memoryHistory will not directly redirect, but it will set notMatchBasename to true + expect(history.location.notMatchBasename).toBe(true); + expect(current.redirected).toBe(true); + expect(current.pathname).toBe('/does-not-match-base'); + }); +}); diff --git a/packages/router/src/history/base.ts b/packages/router/src/history/base.ts index 52a78b25c..a36918907 100644 --- a/packages/router/src/history/base.ts +++ b/packages/router/src/history/base.ts @@ -12,7 +12,8 @@ import { createLocation, createEvents, resolvePath, - pathToString + pathToString, + normalizeBase } from '../utils'; /** @@ -37,6 +38,12 @@ export const ACTION_PUSH: Action = 'PUSH'; */ export const ACTION_REPLACE: Action = 'REPLACE'; +export type TransitionEvent = { + location: Location; + state: HistoryState; + url: string; +}; + export interface TransitionOptions { state?: State; action?: Action; @@ -60,9 +67,19 @@ export interface PushOptions { skipGuards?: boolean; } +export type BaseHistoryOptions = { + basename?: string; +}; + export default abstract class BaseHistory { action: Action = ACTION_POP; location: Location = createLocation('/'); + basename: string; + + constructor({ basename = '' }: BaseHistoryOptions = {}) { + this.basename = normalizeBase(basename); + } + doTransition: ( to: PathRecord, onComplete: Function, @@ -78,6 +95,8 @@ export default abstract class BaseHistory { // ### implemented by sub-classes ### // base interface protected abstract getIndexAndLocation(): [number /* index */, Location]; + + /** setup will be called at `onTransition` and `onAbort` */ abstract setup(): void; // history interface @@ -115,7 +134,7 @@ export default abstract class BaseHistory { const toPath = resolvePath(to, from); return { path: toPath, - href: pathToString(toPath) + href: pathToString(toPath, this.basename) }; } diff --git a/packages/router/src/history/browser.ts b/packages/router/src/history/browser.ts index 2ce7a3f2d..12a4e6f71 100644 --- a/packages/router/src/history/browser.ts +++ b/packages/router/src/history/browser.ts @@ -12,22 +12,42 @@ import { addBlocker, warning } from '../utils'; -import BaseHistory, { PushOptions, ACTION_POP, ACTION_REPLACE } from './base'; +import BaseHistory, { + PushOptions, + ACTION_POP, + ACTION_REPLACE, + BaseHistoryOptions +} from './base'; + +export type BrowserHistoryOptions = BaseHistoryOptions; export default class BrowserHistory extends BaseHistory { private _history: GlobalHistory = window.history; - constructor() { - super(); - + constructor({ basename }: BrowserHistoryOptions = {}) { + super({ basename }); [this._index, this.location] = this.getIndexAndLocation(); - if (this._index == null) { - this._index = 0; + + // redirect immediately if + // 1. no index + // 2. we're not on the right url (redirectedFrom means url not match basename) + const { notMatchBasename } = this.location; + if (this._index == null || notMatchBasename) { + this._index = this._index || 0; this._history.replaceState( { ...this._history.state, idx: this._index }, - '' + '', + notMatchBasename ? this.resolve(this.location).href : undefined ); } + // recalculate location if not match basename + if (notMatchBasename) { + const state = this._history.state || {}; + this.location = createLocation(this.location, { + state: state.usr || null, + key: state.key || 'default' + }); + } } push(to: PathRecord, { state, redirectedFrom }: PushOptions = {}) { @@ -127,6 +147,7 @@ export default class BrowserHistory extends BaseHistory { hash }, { + basename: this.basename, state: state.usr || null, key: state.key || 'default' } diff --git a/packages/router/src/history/hash.ts b/packages/router/src/history/hash.ts index b2cf91e1d..aaf6c1444 100644 --- a/packages/router/src/history/hash.ts +++ b/packages/router/src/history/hash.ts @@ -15,42 +15,45 @@ import { pathToString, warning } from '../utils'; -import BaseHistory, { PushOptions, ACTION_POP, ACTION_REPLACE } from './base'; - -function getBaseHref() { - let base = document.querySelector('base'); - let href = ''; - - if (base && base.getAttribute('href')) { - let url = window.location.href; - let hashIndex = url.indexOf('#'); - href = hashIndex === -1 ? url : url.slice(0, hashIndex); - } - - return href; +import BaseHistory, { + PushOptions, + ACTION_POP, + ACTION_REPLACE, + BaseHistoryOptions +} from './base'; + +function createHref(to: PathRecord, basename?: string) { + return '#' + pathToString(resolvePath(to), basename); } -function createHref(to: PathRecord) { - return ( - getBaseHref() + - '#' + - (typeof to === 'string' ? to : pathToString(resolvePath(to))) - ); -} +export type HashHistoryOptions = BaseHistoryOptions; export default class HashHistory extends BaseHistory { private _history: GlobalHistory = window.history; - constructor() { - super(); + constructor({ basename }: HashHistoryOptions = {}) { + super({ basename }); [this._index, this.location] = this.getIndexAndLocation(); - if (this._index == null) { - this._index = 0; + // redirect immediately if + // 1. no index + // 2. we're not on the right url (redirectedFrom means url not match basename) + const { notMatchBasename } = this.location; + if (this._index == null || notMatchBasename) { + this._index = this._index || 0; this._history.replaceState( { ...this._history.state, idx: this._index }, - '' + '', + notMatchBasename ? this.resolve(this.location).href : undefined ); } + // recalculate location if not match basename + if (notMatchBasename) { + const state = this._history.state || {}; + this.location = createLocation(this.location, { + state: state.usr || null, + key: state.key || 'default' + }); + } } push(to: PathRecord, { state, redirectedFrom }: PushOptions = {}) { @@ -86,7 +89,7 @@ export default class HashHistory extends BaseHistory { const toPath = resolvePath(to, from); return { path: toPath, - href: createHref(toPath) + href: createHref(toPath, this.basename) }; } @@ -170,6 +173,7 @@ export default class HashHistory extends BaseHistory { hash }, { + basename: this.basename, state: state.usr || null, key: state.key || 'default' } diff --git a/packages/router/src/history/index.ts b/packages/router/src/history/index.ts index 0edf0fc80..291dcf349 100644 --- a/packages/router/src/history/index.ts +++ b/packages/router/src/history/index.ts @@ -1,17 +1,21 @@ import MemoryHistory, { MemoryHistoryOptions, InitialEntry } from './memory'; -import BrowserHistory from './browser'; -import HashHistory from './hash'; +import BrowserHistory, { BrowserHistoryOptions } from './browser'; +import HashHistory, { HashHistoryOptions } from './hash'; export * from './base'; export { MemoryHistory, MemoryHistoryOptions, InitialEntry }; -export function createBrowserHistory(): BrowserHistory { - return new BrowserHistory(); +export function createBrowserHistory( + options: BrowserHistoryOptions = {} +): BrowserHistory { + return new BrowserHistory(options); } -export function createHashHistory(): HashHistory { - return new HashHistory(); +export function createHashHistory( + options: HashHistoryOptions = {} +): HashHistory { + return new HashHistory(options); } export function createMemoryHistory( diff --git a/packages/router/src/history/memory.ts b/packages/router/src/history/memory.ts index f9597af80..3ae83cf28 100644 --- a/packages/router/src/history/memory.ts +++ b/packages/router/src/history/memory.ts @@ -1,11 +1,5 @@ -import { - PathRecord, - Location, - Blocker, - PartialLocation, - ResolvedPath -} from '../types'; -import { createLocation, resolvePath, pathToString, warning } from '../utils'; +import { PathRecord, Location, Blocker, PartialLocation } from '../types'; +import { createLocation, resolvePath, warning } from '../utils'; import BaseHistory, { PushOptions, ACTION_POP, ACTION_REPLACE } from './base'; function clamp(n: number, lowerBound: number, upperBound: number) { @@ -21,6 +15,7 @@ export type InitialEntry = string | PartialLocation; export type MemoryHistoryOptions = { initialEntries?: InitialEntry[]; initialIndex?: number; + basename?: string; }; export default class MemoryHistory extends BaseHistory { @@ -28,17 +23,20 @@ export default class MemoryHistory extends BaseHistory { constructor({ initialEntries = ['/'], - initialIndex + initialIndex, + basename = '' }: MemoryHistoryOptions = {}) { - super(); + super({ basename }); this._entries = initialEntries.map(entry => { - let location = createLocation({ - pathname: '/', - search: '', - hash: '', - ...(typeof entry === 'string' ? resolvePath(entry) : entry) - }); - + let location = createLocation( + { + pathname: '/', + search: '', + hash: '', + ...(typeof entry === 'string' ? resolvePath(entry) : entry) + }, + { basename: this.basename } + ); warning( location.pathname.charAt(0) === '/', `Relative pathnames are not supported in createMemoryHistory({ initialEntries }) (invalid entry: ${JSON.stringify( @@ -120,14 +118,6 @@ export default class MemoryHistory extends BaseHistory { return this._blockers.push(blocker); } - resolve(to: PathRecord, from?: string): ResolvedPath { - const toPath = resolvePath(to, from); - return { - path: toPath, - href: pathToString(toPath) - }; - } - protected getIndexAndLocation(): [number, Location] { const index = this._index; return [index, this._entries[index]]; diff --git a/packages/router/src/matchRoutes.ts b/packages/router/src/matchRoutes.ts index 26a1ba88a..4e7c366ae 100644 --- a/packages/router/src/matchRoutes.ts +++ b/packages/router/src/matchRoutes.ts @@ -108,22 +108,18 @@ function flattenRoutes( export function matchRoutes( routes: T[], - location: string | PartialLocation, - basename = '' + location: string | PartialLocation ): IRouteMatch[] | null { if (typeof location === 'string') { location = resolvePath(location); } - let pathname = location.pathname || '/'; - if (basename) { - let base = basename.replace(/^\/*/, '/').replace(/\/+$/, ''); - if (pathname.startsWith(base)) { - pathname = pathname === base ? '/' : pathname.slice(base.length); - } else { - // Pathname does not start with the basename, no match. - return null; - } + let pathname = location.pathname; + + // If pathname is empty, it means invalid pathname and cannot match any route. + // This only happens when basename is set but url is not start with basename + if (!pathname) { + return null; } let branches = flattenRoutes(routes); diff --git a/packages/router/src/router.ts b/packages/router/src/router.ts index 99272d73d..f30cd209c 100644 --- a/packages/router/src/router.ts +++ b/packages/router/src/router.ts @@ -16,13 +16,7 @@ import { } from './types'; import { matchRoutes } from './matchRoutes'; import { createRoutesFromArray } from './createRoutesFromArray'; -import { - normalizeBase, - joinPaths, - createEvents, - resolvePath, - Events -} from './utils'; +import { createEvents, resolvePath, Events } from './utils'; import { isError, isFunction } from './utils/error'; import { runQueue } from './utils/async'; import History from './history/base'; @@ -44,11 +38,9 @@ interface IRouterOptions { history: History; routes: RouteRecord[]; caseSensitive?: boolean; - basename?: string; } class Router implements IRouter { - private _basename: string; private _history: History; private _routes: RouteRecord[]; private _current: IRoute; @@ -62,8 +54,7 @@ class Router implements IRouter { private _beforeResolves: Events = createEvents(); private _afterEachs: Events = createEvents(); - constructor({ basename = '', history, routes }: IRouterOptions) { - this._basename = normalizeBase(basename); + constructor({ history, routes }: IRouterOptions) { this._history = history; this._routes = createRoutesFromArray(routes); this._current = START; @@ -86,11 +77,20 @@ class Router implements IRouter { return this._history.action; } + get basename(): string { + return this._history.basename; + } + init = () => { const setup = () => this._history.setup(); - this._history.transitionTo(this._getCurrent(), { + const current = this._getCurrent(); + this._history.transitionTo(current, { onTransition: setup, - onAbort: setup + onAbort: setup, + // current.redirected means the initial url does not match basename and should redirect + // so we just skip all guards + // this logic only applies to memory history + skipGuards: Boolean(current.redirected) }); return this; }; @@ -136,15 +136,12 @@ class Router implements IRouter { }; resolve = (to: PathRecord, from?: any): ResolvedPath => { - return this._history.resolve( - to, - from ? joinPaths([this._basename, from]) : this._basename - ); + return this._history.resolve(to, from); }; match = (to: PathRecord): Array> => { - const { _routes: routes, _basename: basename } = this; - const matches = matchRoutes(routes, to, basename); + const { _routes: routes } = this; + const matches = matchRoutes(routes, to); return matches || []; }; @@ -349,7 +346,7 @@ class Router implements IRouter { hash: location.hash, query: location.query, state: location.state, - redirected: !!location.redirectedFrom, + redirected: Boolean(location.redirectedFrom) || location.notMatchBasename, key: location.key }; } diff --git a/packages/router/src/types/history.ts b/packages/router/src/types/history.ts index 01bf1de00..88ace2d61 100644 --- a/packages/router/src/types/history.ts +++ b/packages/router/src/types/history.ts @@ -115,6 +115,8 @@ export interface PartialPath { export interface Location extends Path { redirectedFrom?: Path; + notMatchBasename?: boolean; + /** * An object of arbitrary data associated with this location. * @@ -135,6 +137,8 @@ export interface Location extends Path { * A partial Location object that may be missing some properties. */ export interface PartialLocation extends PartialPath { + redirectedFrom?: Path; + /** * An object of arbitrary data associated with this location. * diff --git a/packages/router/src/types/router.ts b/packages/router/src/types/router.ts index 25dafaa5a..1660f1fbc 100644 --- a/packages/router/src/types/router.ts +++ b/packages/router/src/types/router.ts @@ -94,6 +94,7 @@ export interface IRouter< > { current: IRoute; action: History['action']; + basename: string; push(to: PathRecord, state?: any): void; replace(to: PathRecord, state?: any): void; go: History['go']; diff --git a/packages/router/src/utils/dom.ts b/packages/router/src/utils/dom.ts deleted file mode 100644 index 6ea623bfc..000000000 --- a/packages/router/src/utils/dom.ts +++ /dev/null @@ -1 +0,0 @@ -export const inBrowser = typeof window !== 'undefined'; diff --git a/packages/router/src/utils/history.ts b/packages/router/src/utils/history.ts index 5fb3b144e..ef608b6c7 100644 --- a/packages/router/src/utils/history.ts +++ b/packages/router/src/utils/history.ts @@ -8,7 +8,7 @@ import { Path } from '../types'; import { readOnly, Events } from './misc'; -import { resolvePath } from './path'; +import { resolvePath, stripBase } from './path'; const BeforeUnloadEventType = 'beforeunload'; @@ -26,14 +26,22 @@ function createKey() { export function createLocation( to: PathRecord, { + basename, state = null, key, redirectedFrom - }: { state?: State; key?: Key; redirectedFrom?: Path } = {} + }: { basename?: string; state?: State; key?: Key; redirectedFrom?: Path } = {} ) { + const resolved = resolvePath(to); + const pathnameWithoutBase = stripBase(resolved.pathname, basename || '/'); + if (pathnameWithoutBase) { + resolved.pathname = pathnameWithoutBase; + } + const notMatchBasename = Boolean(basename) && !pathnameWithoutBase; return readOnly>({ - ...resolvePath(to), + ...resolved, redirectedFrom, + notMatchBasename, state, key: key || createKey() }); diff --git a/packages/router/src/utils/path.ts b/packages/router/src/utils/path.ts index a53f43503..8a291f1c4 100644 --- a/packages/router/src/utils/path.ts +++ b/packages/router/src/utils/path.ts @@ -1,5 +1,4 @@ import * as qs from 'query-string'; -import { inBrowser } from './dom'; import { PathRecord, PartialPath, Path } from '../types'; export const trimTrailingSlashes = (path: string) => path.replace(/\/+$/, ''); @@ -12,15 +11,7 @@ export const splitPath = (path: string) => normalizeSlashes(path).split('/'); export function normalizeBase(base: string): string { if (!base) { - if (inBrowser) { - // respect tag - const baseEl = document.querySelector('base'); - base = (baseEl && baseEl.getAttribute('href')) || '/'; - // strip full URL origin - base = base.replace(/^https?:\/\/[^\/]+/, ''); - } else { - base = '/'; - } + base = '/'; } // make sure there's the starting slash if (base.charAt(0) !== '/') { @@ -34,17 +25,22 @@ export function parseQuery(queryStr: string) { return qs.parse(queryStr); } -export function pathToString({ - pathname = '/', - search = '', - hash = '', - query = {} -}: PartialPath): string { +export function pathToString( + { pathname = '/', search = '', hash = '', query = {} }: Path, + basename?: string +): string { if (!search) { const queryString = qs.stringify(query); search = queryString ? `?${queryString}` : ''; } - return pathname + search + hash; + const pathString = pathname + search + hash; + if (basename) { + if (pathString === '/') { + return basename; + } + return joinPaths([basename, pathString]); + } + return pathString; } function resolvePathname(toPathname: string, fromPathname: string): string { @@ -120,3 +116,19 @@ export function resolvePath(to: PathRecord, fromPathname = '/'): Path { return parsedPath; } + +/** + * Strips off the base from the beginning of a location.pathname in a non-case-sensitive way. + * + * @param pathname - location.pathname + * @param base - base to strip off + */ +export function stripBase(pathname: string, base: string): string | null { + if (!base || base === '/') return pathname; + + // no base or base is not found at the beginning + if (!pathname.toLowerCase().startsWith(base.toLowerCase())) { + return null; + } + return pathname.slice(base.length) || '/'; +} diff --git a/packages/service/src/server/middlewares/setupAppConfigMiddleware.ts b/packages/service/src/server/middlewares/setupAppConfigMiddleware.ts new file mode 100644 index 000000000..689284241 --- /dev/null +++ b/packages/service/src/server/middlewares/setupAppConfigMiddleware.ts @@ -0,0 +1,19 @@ +import { IServerPluginContext } from '../plugin'; +import { ShuviRequestHandler } from '../shuviServerTypes'; + +export const setupAppConfigMiddleware = ( + context: IServerPluginContext +): ShuviRequestHandler => { + return async (req, _res, next) => { + const appConfig = context.serverPluginRunner.getAppConfig({ req }); + if (appConfig) { + if (typeof appConfig.router.basename !== 'string') { + throw new Error( + '[ServerPlugin Hook getAppConfig] appConfig.router.basename must be a string' + ); + } + context.appConfig = appConfig; + } + next(); + }; +}; diff --git a/packages/service/src/server/plugin.ts b/packages/service/src/server/plugin.ts index 5c3b0d7da..df0ceeba2 100644 --- a/packages/service/src/server/plugin.ts +++ b/packages/service/src/server/plugin.ts @@ -1,6 +1,7 @@ import { createAsyncParallelHook, createHookManager, + createSyncBailHook, IPluginInstance, IPluginHandlers, HookMap @@ -8,11 +9,15 @@ import { import { createPluginCreator } from '@shuvi/shared/plugins'; import { IPluginContext } from '../core'; import { CustomServerPluginHooks } from './pluginTypes'; +import { IRouter } from '@shuvi/router'; +import { ShuviRequest } from './shuviServerTypes'; export * from './pluginTypes'; export interface IServerPluginContext extends IPluginContext { serverPluginRunner: PluginManager['runner']; + appConfig: AppConfig; + router?: IRouter; } export type PluginManager = ReturnType; @@ -21,12 +26,25 @@ export type PluginRunner = PluginManager['runner']; const listen = createAsyncParallelHook<{ port: number; hostname?: string }>(); +type AppConfigCtx = { + req: ShuviRequest; +}; + +type AppConfig = { + router: { + basename: string; + }; +}; +const getAppConfig = createSyncBailHook(); + const internalHooks = { - listen + listen, + getAppConfig }; export interface BuiltInServerPluginHooks extends HookMap { listen: typeof listen; + getAppConfig: typeof getAppConfig; } export interface ServerPluginHooks @@ -63,7 +81,13 @@ export const initServerPlugins = ( ): IServerPluginContext => { const serverContext = Object.assign( { - serverPluginRunner: manager.runner + serverPluginRunner: manager.runner, + // default appConfig, can be override by setupAppConfigMiddleware + appConfig: { + router: { + basename: '' + } + } }, pluginContext ); diff --git a/packages/service/src/server/shuviDevServer.ts b/packages/service/src/server/shuviDevServer.ts index fcce7d771..78a434bd4 100644 --- a/packages/service/src/server/shuviDevServer.ts +++ b/packages/service/src/server/shuviDevServer.ts @@ -23,6 +23,7 @@ import { applyHttpProxyMiddleware } from './middlewares/httpProxyMiddleware'; import { getAssetMiddleware } from './middlewares/getAssetMiddleware'; import { ShuviDevServerOptions, ShuviRequestHandler } from './shuviServerTypes'; import { loadDotenvConfig } from '../config/env'; +import { setupAppConfigMiddleware } from './middlewares/setupAppConfigMiddleware'; export class ShuviDevServer extends ShuviServer { private _bundler: Bundler; @@ -60,6 +61,8 @@ export class ShuviDevServer extends ShuviServer { next(); }) as ShuviRequestHandler); + server.use(setupAppConfigMiddleware(context)); + if (this._options.getMiddlewaresBeforeDevMiddlewares) { const serverMiddlewaresBeforeDevMiddleware = [ this._options.getMiddlewaresBeforeDevMiddlewares(devMiddleware, context) diff --git a/packages/service/src/server/shuviProdServer.ts b/packages/service/src/server/shuviProdServer.ts index a74366872..d6046c2e7 100644 --- a/packages/service/src/server/shuviProdServer.ts +++ b/packages/service/src/server/shuviProdServer.ts @@ -1,9 +1,11 @@ import { ShuviServer } from './shuviServer'; import { getAssetMiddleware } from './middlewares/getAssetMiddleware'; +import { setupAppConfigMiddleware } from './middlewares/setupAppConfigMiddleware'; export class ShuviProdServer extends ShuviServer { async init() { const { _serverContext: context } = this; this._server.use(getAssetMiddleware(context)); + this._server.use(setupAppConfigMiddleware(context)); await this._initMiddlewares(); } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 24f918e75..a3c48eb59 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -995,6 +995,12 @@ importers: specifier: workspace:* version: link:../../packages/shared + test/fixtures/basename: + dependencies: + shuvi: + specifier: workspace:* + version: link:../../../packages/shuvi + test/fixtures/basic: dependencies: shuvi: diff --git a/test/e2e/basename.test.ts b/test/e2e/basename.test.ts new file mode 100644 index 000000000..ef5966d63 --- /dev/null +++ b/test/e2e/basename.test.ts @@ -0,0 +1,95 @@ +import { AppCtx, Page, devFixture, ShuviConfig } from '../utils'; + +jest.setTimeout(5 * 60 * 1000); + +describe('Basename Support', () => { + let ctx: AppCtx; + let page: Page; + + describe.each(['SSR', 'SPA Browser', 'SPA Hash'])(`Basename in %s`, mode => { + let shuviConfig: ShuviConfig = { + ssr: true, + router: { + history: 'browser' + }, + plugins: [['./plugin', { basename: '/base-name' }]] + }; + if (mode === 'SPA Browser') { + shuviConfig.ssr = false; + } + if (mode === 'SPA Hash') { + shuviConfig.ssr = false; + shuviConfig.router!.history = 'hash'; + } + + let indexUrl = '/base-name'; + if (mode === 'SPA Hash') { + indexUrl = '/#/base-name'; + } + + let aboutUrl = '/base-name/about'; + if (mode === 'SPA Hash') { + aboutUrl = '/#/base-name/about'; + } + + beforeAll(async () => { + ctx = await devFixture('basename', shuviConfig); + }); + afterAll(async () => { + await page.close(); + await ctx.close(); + }); + + test('index page should redirect to base when basename is set', async () => { + page = await ctx.browser.page(ctx.url('/')); + expect(page.url()).toBe(ctx.url(indexUrl)); + }); + + test('basename should work for route matching', async () => { + page = await ctx.browser.page(ctx.url('/')); + await page.waitForSelector('#index'); + expect(await page.$text('#index')).toEqual('Index Page'); + + let aboutUrl = '/base-name/about'; + if (mode === 'SPA Hash') { + aboutUrl = '/#/base-name/about'; + } + + page = await ctx.browser.page(ctx.url(aboutUrl)); + expect(await page.$text('#about')).toEqual('About Page'); + }); + + test('basename should work when client navigation', async () => { + page = await ctx.browser.page(ctx.url('/')); + await page.waitForSelector('#index'); + + expect(await page.$text('#index')).toEqual('Index Page'); + + await page.shuvi.navigate('/about'); + await page.waitForSelector('#about'); + expect(await page.$text('#about')).toEqual('About Page'); + + await page.shuvi.navigate('/list'); + await page.waitForSelector('#list'); + expect(await page.$text('#list')).toEqual('List Page'); + + await page.shuvi.navigate('/'); + await page.waitForSelector('#index'); + expect(await page.$text('#index')).toEqual('Index Page'); + }); + }); + + describe('Basename Verify', () => { + test('basename must be a string', async () => { + ctx = await devFixture('basename', { + plugins: [['./plugin', { basename: null }]] + }); + page = await ctx.browser.page(); + const result = await page.goto(ctx.url('/base-name')); + expect(result?.status()).toBe(500); + expect(await page.$text('body')).toContain( + 'appConfig.router.basename must be a string' + ); + }); + }); +}); diff --git a/test/e2e/loader.test.ts b/test/e2e/loader.test.ts index e0e1c1cd8..142e90a7e 100644 --- a/test/e2e/loader.test.ts +++ b/test/e2e/loader.test.ts @@ -1,13 +1,35 @@ -import { AppCtx, Page, devFixture, buildFixture, serveFixture } from '../utils'; +import { + AppCtx, + Page, + devFixture, + buildFixture, + serveFixture, + ShuviConfig +} from '../utils'; jest.setTimeout(5 * 60 * 1000); -describe('loader', () => { +describe.each([false, true])('loader', hasBasename => { let ctx: AppCtx; let page: Page; + const baseShuviConfig: ShuviConfig = { + plugins: [['./plugin', { basename: hasBasename ? '/base' : '/' }]] + }; + + const getUrl = (path: string) => { + if (hasBasename) { + if (path === '/') { + return '/base'; + } + return '/base' + path; + } else { + return path; + } + }; + describe('ssr = true', () => { beforeAll(async () => { - ctx = await devFixture('loader', { ssr: true }); + ctx = await devFixture('loader', { ssr: true, ...baseShuviConfig }); }); afterAll(async () => { await ctx.close(); @@ -43,7 +65,7 @@ describe('loader', () => { const req = loaderData.req; expect(typeof req.headers).toBe('object'); - expect(req.url).toBe('/test?a=2'); + expect(req.url).toBe(getUrl('/test?a=2')); expect(req.query).toEqual({ a: '2' }); expect(loaderData.query.a).toBe('2'); @@ -91,7 +113,7 @@ describe('loader', () => { it('should support redirect chain in SSR', async () => { const responses: { url: string; status: number }[] = []; - page = await ctx.browser.page(ctx.url('/')); + page = await ctx.browser.page(); page.on('request', request => { request.continue(); }); @@ -108,30 +130,42 @@ describe('loader', () => { }); await page.setRequestInterception(true); await page.goto( - ctx.url('/context/redirect', { target: '/context/redirect/combo/a' }) + ctx.url('/context/redirect', { + target: '/context/redirect/combo/a' + }) + ); + expect(responses).toEqual( + [ + hasBasename + ? { + url: ctx.url('/context/redirect', { + target: '/context/redirect/combo/a' + }), + status: 302 + } + : undefined, + { + url: ctx.url(getUrl('/context/redirect'), { + target: '/context/redirect/combo/a' + }), + status: 302 + }, + // default status code + { + url: ctx.url(getUrl('/context/redirect/combo/a')), + status: 302 + }, + // custom status code + { + url: ctx.url(getUrl('/context/redirect/combo/b')), + status: 307 + }, + { + url: ctx.url(getUrl('/context/redirect/combo/c')), + status: 200 + } + ].filter(x => x) ); - expect(responses).toEqual([ - { - url: ctx.url('/context/redirect', { - target: '/context/redirect/combo/a' - }), - status: 302 - }, - // default status code - { - url: ctx.url('/context/redirect/combo/a'), - status: 302 - }, - // custom status code - { - url: ctx.url('/context/redirect/combo/b'), - status: 307 - }, - { - url: ctx.url('/context/redirect/combo/c'), - status: 200 - } - ]); }); it('should support params and query in SSR', async () => { @@ -153,18 +187,28 @@ describe('loader', () => { }); await page.setRequestInterception(true); await page.goto(ctx.url('/context/redirect', { target: FULL_URL })); - expect(responses).toEqual([ - { - url: ctx.url('/context/redirect', { - target: FULL_URL - }), - status: 302 - }, - { - url: ctx.url(FULL_URL), - status: 200 - } - ]); + expect(responses).toEqual( + [ + hasBasename + ? { + url: ctx.url('/context/redirect', { + target: FULL_URL + }), + status: 302 + } + : undefined, + { + url: ctx.url(getUrl('/context/redirect'), { + target: FULL_URL + }), + status: 302 + }, + { + url: ctx.url(getUrl(FULL_URL)), + status: 200 + } + ].filter(x => x) + ); await page.waitForSelector('#url-data'); expect(await page.$text('#url-data')).toBe( '{"query":{"query":"1"},"params":{"d":"params"},"pathname":"/context/redirect/combo/params"}' @@ -225,14 +269,24 @@ describe('loader', () => { await page.goto( ctx.url('/context/redirect', { target: THIRD_PARTY_SITE }) ); - expect(responses).toEqual([ - { - url: ctx.url('/context/redirect', { - target: THIRD_PARTY_SITE - }), - status: 302 - } - ]); + expect(responses).toEqual( + [ + hasBasename + ? { + url: ctx.url('/context/redirect', { + target: THIRD_PARTY_SITE + }), + status: 302 + } + : undefined, + { + url: ctx.url(getUrl('/context/redirect'), { + target: THIRD_PARTY_SITE + }), + status: 302 + } + ].filter(x => x) + ); }); it('should support redirect chain in client route navigation', async () => { @@ -244,7 +298,7 @@ describe('loader', () => { expect(await page.$text('#page-content')).toBe('C'); await page.goBack(); - expect(page.url()).toBe(ctx.url('/')); + expect(page.url()).toBe(ctx.url(getUrl('/'))); await page.waitForSelector('#index-content'); expect(await page.$text('#index-content')).toBe('index page'); }); @@ -301,11 +355,13 @@ describe('loader', () => { beforeAll(async () => { buildFixture('loader', { ssr: false, - router: { history: 'browser' } + router: { history: 'browser' }, + ...baseShuviConfig }); ctx = await serveFixture('loader', { ssr: false, - router: { history: 'browser' } + router: { history: 'browser' }, + ...baseShuviConfig }); }); afterEach(async () => { @@ -374,6 +430,7 @@ describe('loader', () => { it('should support redirect chain in CSR', async () => { page = await ctx.browser.page(ctx.url('/')); + await page.waitForSelector('#index-content'); await page.goto( ctx.url('/context/redirect', { target: '/context/redirect/combo/a' }) ); @@ -382,13 +439,14 @@ describe('loader', () => { expect(await page.$text('#page-content')).toBe('C'); await page.goBack(); - expect(page.url()).toBe(ctx.url('/')); + expect(page.url()).toBe(ctx.url(getUrl('/'))); await page.waitForSelector('#index-content'); expect(await page.$text('#index-content')).toBe('index page'); }); it('should support params and query in CSR', async () => { page = await ctx.browser.page(ctx.url('/')); + await page.waitForSelector('#index-content'); await page.goto(ctx.url('/context/redirect', { target: FULL_URL })); await page.waitForSelector('#url-data'); expect(await page.$text('#url-data')).toBe( @@ -417,7 +475,7 @@ describe('loader', () => { expect(await page.$text('#page-content')).toBe('C'); await page.goBack(); - expect(page.url()).toBe(ctx.url('/')); + expect(page.url()).toBe(ctx.url(getUrl('/'))); await page.waitForSelector('#index-content'); expect(await page.$text('#index-content')).toBe('index page'); }); @@ -449,11 +507,63 @@ describe('loader', () => { expect(await page.$text('#firstHeading')).toContain('React'); }); }); + + describe('loaders should run properly when navigation is triggered', () => { + test(' when initial rendering, all loaders should run', async () => { + page = await ctx.browser.page(ctx.url('/loader-run/foo/a')); + expect(await page.$text('[data-test-id="time-loader-run"]')).toBe('0'); + expect(await page.$text('[data-test-id="time-foo"]')).toBe('0'); + expect(await page.$text('[data-test-id="time-foo-a"]')).toBe('0'); + }); + + test('when matching a new route, its loader and all its children loaders should run', async () => { + page = await ctx.browser.page(ctx.url('/loader-run/')); + expect(await page.$text('[data-test-id="time-loader-run"]')).toBe('0'); + const { texts, dispose } = page.collectBrowserLog(); + await page.shuvi.navigate('/loader-run/foo/a'); + await page.waitForTimeout(100); + expect(await page.$text('[data-test-id="time-loader-run"]')).toBe('0'); + expect(await page.$text('[data-test-id="time-foo"]')).toBe('0'); + expect(await page.$text('[data-test-id="time-foo-a"]')).toBe('0'); + expect(texts.join('')).toMatch( + ['loader-run foo', 'loader-run foo a'].join('') + ); + dispose(); + }); + + test('when matching a same dynamic route but different params, its loader and all its children loaders should run', async () => { + page = await ctx.browser.page(ctx.url('/loader-run/foo/a')); + expect(await page.$text('[data-test-id="time-loader-run"]')).toBe('0'); + expect(await page.$text('[data-test-id="time-foo"]')).toBe('0'); + expect(await page.$text('[data-test-id="param"]')).toBe('foo'); + expect(await page.$text('[data-test-id="time-foo-a"]')).toBe('0'); + const { texts, dispose } = page.collectBrowserLog(); + await page.shuvi.navigate('/loader-run/bar/a'); + expect(await page.$text('[data-test-id="time-foo"]')).toBe('1'); + expect(await page.$text('[data-test-id="param"]')).toBe('bar'); + expect(await page.$text('[data-test-id="time-foo-a"]')).toBe('1'); + + expect(texts.join('')).toMatch( + ['loader-run foo', 'loader-run foo a'].join('') + ); + dispose(); + }); + + test('the loader of last nested route should always run', async () => { + page = await ctx.browser.page(ctx.url('/loader-run/foo/a')); + expect(await page.$text('[data-test-id="time-foo-a"]')).toBe('0'); + const { texts, dispose } = page.collectBrowserLog(); + await page.shuvi.navigate('/loader-run/foo/a', { sss: 123 }); + expect(await page.$text('[data-test-id="time-foo-a"]')).toBe('1'); + expect(texts.join('')).toMatch(['loader-run foo a'].join('')); + dispose(); + }); + }); }); test('loaders should be called in parallel and block navigation', async () => { - buildFixture('loader', { ssr: true }); - const ctx = await serveFixture('loader'); + buildFixture('loader', { ssr: true, ...baseShuviConfig }); + const ctx = await serveFixture('loader', { ssr: true, ...baseShuviConfig }); const page = await ctx.browser.page(ctx.url('/parent')); const { texts, dispose } = page.collectBrowserLog(); await page.shuvi.navigate('/parent/foo/a'); @@ -471,71 +581,4 @@ describe('loader', () => { await page.close(); await ctx.close(); }); - - describe('loaders should run properly when navigation is triggered', () => { - beforeAll(async () => { - buildFixture('loader', { ssr: true }); - ctx = await serveFixture('loader', { - ssr: false, - router: { - history: 'browser' - } - }); - }); - afterEach(async () => { - await page.close(); - }); - afterAll(async () => { - await ctx.close(); - }); - test(' when initial rendering, all loaders should run', async () => { - page = await ctx.browser.page(ctx.url('/loader-run/foo/a')); - expect(await page.$text('[data-test-id="time-loader-run"]')).toBe('0'); - expect(await page.$text('[data-test-id="time-foo"]')).toBe('0'); - expect(await page.$text('[data-test-id="time-foo-a"]')).toBe('0'); - }); - - test('when matching a new route, its loader and all its children loaders should run', async () => { - page = await ctx.browser.page(ctx.url('/loader-run/')); - expect(await page.$text('[data-test-id="time-loader-run"]')).toBe('0'); - const { texts, dispose } = page.collectBrowserLog(); - await page.shuvi.navigate('/loader-run/foo/a'); - await page.waitForTimeout(100); - expect(await page.$text('[data-test-id="time-loader-run"]')).toBe('0'); - expect(await page.$text('[data-test-id="time-foo"]')).toBe('0'); - expect(await page.$text('[data-test-id="time-foo-a"]')).toBe('0'); - expect(texts.join('')).toMatch( - ['loader-run foo', 'loader-run foo a'].join('') - ); - dispose(); - }); - - test('when matching a same dynamic route but different params, its loader and all its children loaders should run', async () => { - page = await ctx.browser.page(ctx.url('/loader-run/foo/a')); - expect(await page.$text('[data-test-id="time-loader-run"]')).toBe('0'); - expect(await page.$text('[data-test-id="time-foo"]')).toBe('0'); - expect(await page.$text('[data-test-id="param"]')).toBe('foo'); - expect(await page.$text('[data-test-id="time-foo-a"]')).toBe('0'); - const { texts, dispose } = page.collectBrowserLog(); - await page.shuvi.navigate('/loader-run/bar/a'); - expect(await page.$text('[data-test-id="time-foo"]')).toBe('1'); - expect(await page.$text('[data-test-id="param"]')).toBe('bar'); - expect(await page.$text('[data-test-id="time-foo-a"]')).toBe('1'); - - expect(texts.join('')).toMatch( - ['loader-run foo', 'loader-run foo a'].join('') - ); - dispose(); - }); - - test('the loader of last nested route should always run', async () => { - page = await ctx.browser.page(ctx.url('/loader-run/foo/a')); - expect(await page.$text('[data-test-id="time-foo-a"]')).toBe('0'); - const { texts, dispose } = page.collectBrowserLog(); - await page.shuvi.navigate('/loader-run/foo/a', { sss: 123 }); - expect(await page.$text('[data-test-id="time-foo-a"]')).toBe('1'); - expect(texts.join('')).toMatch(['loader-run foo a'].join('')); - dispose(); - }); - }); }); diff --git a/test/fixtures/basename/package.json b/test/fixtures/basename/package.json new file mode 100644 index 000000000..f3d1b3309 --- /dev/null +++ b/test/fixtures/basename/package.json @@ -0,0 +1,6 @@ +{ + "name": "fixture-basename", + "dependencies": { + "shuvi": "workspace:*" + } +} diff --git a/test/fixtures/basename/plugin/server.js b/test/fixtures/basename/plugin/server.js new file mode 100644 index 000000000..01a09ff94 --- /dev/null +++ b/test/fixtures/basename/plugin/server.js @@ -0,0 +1,15 @@ +const { createServerPlugin } = require('shuvi'); + +module.exports = ({ basename = '' }) => + createServerPlugin({ + getAppConfig: ({ req }) => { + global._req_url = req.url; + const appConfig = { + router: { + basename + } + }; + global._app_config = appConfig; + return appConfig; + } + }); diff --git a/test/fixtures/basename/shuvi.config.js b/test/fixtures/basename/shuvi.config.js new file mode 100644 index 000000000..f57b9867a --- /dev/null +++ b/test/fixtures/basename/shuvi.config.js @@ -0,0 +1,7 @@ +export default { + ssr: false, + router: { + history: 'browser' + }, + plugins: [['./plugin', { basename: '/base-name' }]] +}; diff --git a/test/fixtures/basename/src/routes/about/page.js b/test/fixtures/basename/src/routes/about/page.js new file mode 100644 index 000000000..134fbb09b --- /dev/null +++ b/test/fixtures/basename/src/routes/about/page.js @@ -0,0 +1,18 @@ +import { Link } from '@shuvi/runtime'; +export default function Page() { + return ( +
+
About Page
+
+ + Go to / + +
+
+ + Go to /list + +
+
+ ); +} diff --git a/test/fixtures/basename/src/routes/layout.js b/test/fixtures/basename/src/routes/layout.js new file mode 100644 index 000000000..ce4518ec0 --- /dev/null +++ b/test/fixtures/basename/src/routes/layout.js @@ -0,0 +1,7 @@ +import { RouterView, useRouter, useCurrentRoute } from '@shuvi/runtime'; +export default function Page() { + const router = useRouter(); + const route = useCurrentRoute(); + console.log('----router', router.resolve(route)); + return ; +} diff --git a/test/fixtures/basename/src/routes/list/page.js b/test/fixtures/basename/src/routes/list/page.js new file mode 100644 index 000000000..70d1a45b8 --- /dev/null +++ b/test/fixtures/basename/src/routes/list/page.js @@ -0,0 +1,13 @@ +import { Link } from '@shuvi/runtime'; +export default function Page() { + return ( +
+
List Page
+
+ + Go to /about + +
+
+ ); +} diff --git a/test/fixtures/basename/src/routes/page.js b/test/fixtures/basename/src/routes/page.js new file mode 100644 index 000000000..d63b1de02 --- /dev/null +++ b/test/fixtures/basename/src/routes/page.js @@ -0,0 +1,32 @@ +import { Link, useRouter } from '@shuvi/runtime'; +export default function Page() { + const router = useRouter(); + const goToAbout = () => { + router.push('/about'); + }; + return ( +
+
Index Page
+
+ + Go to /about + +
+
+ + Go to /list + +
+
+ +
+
+ ); +} + +export const loader = ctx => { + console.log('--------------page loader', ctx.pathname); + return {}; +}; diff --git a/test/fixtures/loader/plugin/server.js b/test/fixtures/loader/plugin/server.js new file mode 100644 index 000000000..01a09ff94 --- /dev/null +++ b/test/fixtures/loader/plugin/server.js @@ -0,0 +1,15 @@ +const { createServerPlugin } = require('shuvi'); + +module.exports = ({ basename = '' }) => + createServerPlugin({ + getAppConfig: ({ req }) => { + global._req_url = req.url; + const appConfig = { + router: { + basename + } + }; + global._app_config = appConfig; + return appConfig; + } + }); diff --git a/test/fixtures/loader/shuvi.config.ts b/test/fixtures/loader/shuvi.config.ts index 10f66f968..c9a93d03a 100644 --- a/test/fixtures/loader/shuvi.config.ts +++ b/test/fixtures/loader/shuvi.config.ts @@ -1,7 +1,8 @@ import { defineConfig } from 'shuvi'; export default defineConfig({ - ssr: true, + ssr: false, router: { history: 'browser' - } + }, + plugins: [['./plugin', { basename: '/base' }]] }); diff --git a/test/utils/launcher.ts b/test/utils/launcher.ts index 0bf0f7ff2..c95b161f7 100644 --- a/test/utils/launcher.ts +++ b/test/utils/launcher.ts @@ -11,6 +11,7 @@ import { SpawnOptions, ChildProcess } from 'child_process'; import * as rimraf from 'rimraf'; import * as path from 'path'; +export type { ShuviConfig }; export interface AppCtx { browser: Browser; url: (x: string, query?: Record) => string; @@ -191,6 +192,7 @@ export interface LaunchOptions { isDev?: boolean; onStdout?: (data: string) => void; onStderr?: (error: string) => void; + basename?: string; } /** remove generated files under '.shuvi' and 'dist' folders to prevent unexpected effect */