├── packages ├── core │ ├── src │ │ ├── components │ │ │ ├── index.ts │ │ │ └── VRouterView.ts │ │ ├── composables │ │ │ ├── index.ts │ │ │ └── useVRouter.ts │ │ ├── constants.ts │ │ ├── router │ │ │ ├── index.ts │ │ │ ├── render.ts │ │ │ ├── register.ts │ │ │ └── create.ts │ │ ├── types │ │ │ └── augments.d.ts │ │ ├── index.ts │ │ ├── utils.ts │ │ ├── plugin.ts │ │ └── types.ts │ ├── tsdown.config.ts │ ├── package.json │ └── test │ │ ├── _utils.ts │ │ ├── plugin.test.ts │ │ ├── composables.test.ts │ │ ├── router.test.ts │ │ └── components.test.ts └── nuxt │ ├── tsdown.config.ts │ ├── src │ ├── plugin.mjs │ └── index.ts │ └── package.json ├── playgrounds └── vite │ ├── src │ ├── main.js │ ├── components │ │ ├── RouteInfo.vue │ │ └── NavigationMenu.vue │ └── App.vue │ ├── index.html │ ├── package.json │ └── vite.config.js ├── .editorconfig ├── .vscode ├── extensions.json └── settings.json ├── tsdown.config.ts ├── eslint.config.ts ├── renovate.json ├── tsconfig.json ├── pnpm-workspace.yaml ├── vitest.config.ts ├── .gitignore ├── scripts └── publish.ts ├── .github └── workflows │ ├── autofix.yml │ ├── publish.yml │ └── ci.yml ├── LICENSE ├── package.json └── README.md /packages/core/src/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from './VRouterView' 2 | -------------------------------------------------------------------------------- /packages/core/src/composables/index.ts: -------------------------------------------------------------------------------- 1 | export * from './useVRouter' 2 | -------------------------------------------------------------------------------- /packages/core/src/constants.ts: -------------------------------------------------------------------------------- 1 | export const virouSymbol = Symbol('virou') 2 | -------------------------------------------------------------------------------- /packages/core/src/router/index.ts: -------------------------------------------------------------------------------- 1 | export * from './create' 2 | export * from './register' 3 | export * from './render' 4 | -------------------------------------------------------------------------------- /packages/core/tsdown.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsdown' 2 | 3 | export default defineConfig({ 4 | entry: 'src/index.ts', 5 | dts: { 6 | sourcemap: true, 7 | }, 8 | }) 9 | -------------------------------------------------------------------------------- /packages/nuxt/tsdown.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsdown' 2 | 3 | export default defineConfig({ 4 | entry: 'src/index.ts', 5 | platform: 'node', 6 | copy: ['src/plugin.mjs'], 7 | }) 8 | -------------------------------------------------------------------------------- /playgrounds/vite/src/main.js: -------------------------------------------------------------------------------- 1 | import { virou } from '@virou/core' 2 | import { createApp } from 'vue' 3 | import App from './App.vue' 4 | 5 | const app = createApp(App) 6 | app.use(virou) 7 | app.mount('#app') 8 | -------------------------------------------------------------------------------- /packages/core/src/types/augments.d.ts: -------------------------------------------------------------------------------- 1 | import type { VRouterData } from '../types' 2 | 3 | declare module 'vue' { 4 | interface ComponentCustomProperties { 5 | $virou: Map 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/core/src/index.ts: -------------------------------------------------------------------------------- 1 | import './types/augments.d.ts' 2 | 3 | export * from './components' 4 | export * from './composables' 5 | export * from './constants' 6 | export * from './plugin' 7 | export * from './router' 8 | export * from './types' 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_size = 2 5 | indent_style = space 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "streetsidesoftware.code-spell-checker", 5 | "aaron-bond.better-comments", 6 | "EditorConfig.EditorConfig", 7 | "redhat.vscode-yaml" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /tsdown.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsdown' 2 | 3 | export default defineConfig({ 4 | workspace: true, 5 | format: 'esm', 6 | fixedExtension: false, 7 | platform: 'browser', 8 | dts: true, 9 | external: [/^@virou\/.*$/], 10 | }) 11 | -------------------------------------------------------------------------------- /packages/nuxt/src/plugin.mjs: -------------------------------------------------------------------------------- 1 | import { defineNuxtPlugin, useRuntimeConfig } from '#imports' 2 | import { virou } from '@virou/core' 3 | 4 | export default defineNuxtPlugin((nuxtApp) => { 5 | const options = useRuntimeConfig().public.virou 6 | 7 | nuxtApp.vueApp.use(virou, options) 8 | }) 9 | -------------------------------------------------------------------------------- /playgrounds/vite/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Vite + Vue 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /eslint.config.ts: -------------------------------------------------------------------------------- 1 | import antfu from '@antfu/eslint-config' 2 | 3 | export default antfu({ 4 | type: 'lib', 5 | pnpm: true, 6 | typescript: { 7 | tsconfigPath: 'tsconfig.json', 8 | }, 9 | yaml: { 10 | overrides: { 11 | 'pnpm/yaml-enforce-settings': 'off', 12 | }, 13 | }, 14 | stylistic: { 15 | overrides: { 16 | 'style/quote-props': 'off', 17 | }, 18 | }, 19 | }) 20 | -------------------------------------------------------------------------------- /playgrounds/vite/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vite-playground", 3 | "type": "module", 4 | "version": "0.0.0", 5 | "private": true, 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "preview": "vite preview" 10 | }, 11 | "dependencies": { 12 | "vue": "catalog:", 13 | "vue-shiki-input": "catalog:" 14 | }, 15 | "devDependencies": { 16 | "@vitejs/plugin-vue": "catalog:", 17 | "vite": "catalog:" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /packages/core/src/utils.ts: -------------------------------------------------------------------------------- 1 | import type { Component } from 'vue' 2 | import type { VRouteLazyComponent, VRouteRenderComponent } from './types' 3 | import { defineAsyncComponent } from 'vue' 4 | 5 | export function normalizeComponent(component: VRouteRenderComponent): Component { 6 | if (typeof component === 'function' && (component as VRouteLazyComponent).length === 0) { 7 | return defineAsyncComponent(component as VRouteLazyComponent) 8 | } 9 | return component as Component 10 | } 11 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:best-practices", 5 | "group:allNonMajor", 6 | ":semanticCommitTypeAll(chore)", 7 | ":widenPeerDependencies", 8 | ":approveMajorUpdates" 9 | ], 10 | "baseBranchPatterns": [ 11 | "main" 12 | ], 13 | "meteor": { 14 | "enabled": false 15 | }, 16 | "rangeStrategy": "bump", 17 | "npm": { 18 | "commitMessageTopic": "{{prettyDepType}} {{depName}}" 19 | }, 20 | "ignoreDeps": ["node"] 21 | } 22 | -------------------------------------------------------------------------------- /packages/core/src/plugin.ts: -------------------------------------------------------------------------------- 1 | import type { Plugin } from 'vue' 2 | import type { VirouPluginOptions, VRouterData } from './types' 3 | import { createVRouter } from './router' 4 | 5 | export const virou: Plugin<[VirouPluginOptions?]> = (app, options = {}) => { 6 | const { routers } = options 7 | 8 | const map = new Map() 9 | app.config.globalProperties.$virou = map 10 | 11 | if (routers) { 12 | for (const [key, router] of Object.entries(routers)) { 13 | map.set(key, createVRouter(router.routes, { ...router.options, isGlobal: true })) 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "baseUrl": ".", 5 | "rootDir": ".", 6 | "module": "ESNext", 7 | "moduleResolution": "Bundler", 8 | "paths": { 9 | "@virou/core": ["packages/core/src/index.ts"], 10 | "@virou/core/*": ["packages/core/src/*"] 11 | }, 12 | "strict": true, 13 | "noEmit": true, 14 | "esModuleInterop": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "isolatedModules": true, 17 | "skipLibCheck": true 18 | }, 19 | "exclude": [ 20 | "node_modules", 21 | "coverage", 22 | "**/dist" 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /playgrounds/vite/vite.config.js: -------------------------------------------------------------------------------- 1 | import { resolve } from 'node:path' 2 | import vue from '@vitejs/plugin-vue' 3 | import { defineConfig } from 'vite' 4 | 5 | export default defineConfig(({ command }) => ({ 6 | plugins: [vue()], 7 | resolve: command === 'build' 8 | ? {} 9 | : { 10 | alias: { 11 | '@virou/core': resolve(__dirname, '../../packages/core/src/index.ts'), 12 | }, 13 | }, 14 | build: { 15 | minify: false, 16 | rollupOptions: { 17 | output: { 18 | manualChunks: (id) => { 19 | if (id.includes('@virou/')) 20 | return 'virou' 21 | else 22 | return 'vendor' 23 | }, 24 | }, 25 | }, 26 | }, 27 | })) 28 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | catalogMode: prefer 2 | 3 | cleanupUnusedCatalogs: true 4 | 5 | shamefullyHoist: true 6 | 7 | shellEmulator: true 8 | packages: 9 | - packages/* 10 | - playgrounds/* 11 | catalog: 12 | '@antfu/eslint-config': 6.7.1 13 | '@nuxt/kit': ^4.2.2 14 | '@nuxt/schema': 4.2.2 15 | '@vitejs/plugin-vue': 6.0.3 16 | '@vitest/coverage-v8': 4.0.16 17 | '@vue/test-utils': 2.4.6 18 | bumpp: 10.3.2 19 | defu: ^6.1.4 20 | eslint: 9.39.2 21 | happy-dom: 20.0.11 22 | installed-check: 9.3.0 23 | lint-staged: 16.2.7 24 | nuxt: 4.2.2 25 | rou3: ^0.7.12 26 | simple-git-hooks: 2.13.1 27 | tsdown: 0.18.1 28 | tsx: 4.21.0 29 | typescript: 5.9.3 30 | ufo: ^1.6.1 31 | vite: 7.3.0 32 | vitest: 4.0.16 33 | vue: ^3.5.26 34 | vue-shiki-input: ^2.1.0 35 | vue-tsc: 3.1.8 36 | 37 | strictPeerDependencies: false 38 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'node:path' 2 | import vue from '@vitejs/plugin-vue' 3 | import { coverageConfigDefaults, defineConfig } from 'vitest/config' 4 | 5 | export default defineConfig({ 6 | plugins: [vue()], 7 | resolve: { 8 | alias: { 9 | '@virou/core': resolve(import.meta.dirname, 'packages/core/src/index.ts'), 10 | }, 11 | }, 12 | test: { 13 | coverage: { 14 | include: [ 15 | 'packages/**/*.ts', 16 | ], 17 | exclude: [ 18 | 'packages/nuxt/**', 19 | '**/*.config.ts', 20 | '**/test/**', 21 | ...coverageConfigDefaults.exclude, 22 | ], 23 | }, 24 | projects: [ 25 | { 26 | extends: true, 27 | test: { 28 | name: 'unit', 29 | environment: 'happy-dom', 30 | }, 31 | }, 32 | ], 33 | }, 34 | }) 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules 3 | 4 | package-lock.json 5 | packages/*/README.md 6 | packages/*/LICENSE 7 | 8 | # Logs 9 | *.log 10 | 11 | # Temp directories 12 | .temp 13 | .tmp 14 | .cache 15 | 16 | # Build directories 17 | dist 18 | 19 | # VSCode 20 | .vscode/* 21 | !.vscode/settings.json 22 | !.vscode/extensions.json 23 | 24 | # Intellij idea 25 | *.iml 26 | .idea 27 | 28 | # OSX 29 | .DS_Store 30 | .AppleDouble 31 | .LSOverride 32 | 33 | # Files that might appear in the root of a volume 34 | .DocumentRevisions-V100 35 | .fseventsd 36 | .Spotlight-V100 37 | .TemporaryItems 38 | .Trashes 39 | .VolumeIcon.icns 40 | .com.apple.timemachine.donotpresent 41 | 42 | # Directories potentially created on remote AFP share 43 | .AppleDB 44 | .AppleDesktop 45 | Network Trash Folder 46 | Temporary Items 47 | .apdisk 48 | 49 | # Environment variables 50 | .env 51 | .env.* 52 | !.env.example 53 | 54 | coverage 55 | .pnpm-store 56 | .eslintcache -------------------------------------------------------------------------------- /playgrounds/vite/src/components/RouteInfo.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 25 | 26 | 35 | -------------------------------------------------------------------------------- /scripts/publish.ts: -------------------------------------------------------------------------------- 1 | import { execSync } from 'node:child_process' 2 | import { copyFile, readdir } from 'node:fs/promises' 3 | import { join, resolve } from 'node:path' 4 | import { version } from '../package.json' 5 | 6 | const rootDir = resolve() 7 | const readmePath = join(rootDir, 'README.md') 8 | const licensePath = join(rootDir, 'LICENSE') 9 | const packagesDir = join(rootDir, 'packages') 10 | 11 | let command = 'pnpm publish -r --access public --no-git-checks' 12 | 13 | if (version.includes('beta')) { 14 | command += ' --tag beta' 15 | } 16 | 17 | const packages = await readdir(packagesDir, { withFileTypes: true }) 18 | 19 | for (const pkg of packages) { 20 | if (!pkg.isDirectory()) 21 | continue 22 | 23 | const targetDir = join(packagesDir, pkg.name) 24 | await Promise.all([ 25 | copyFile(readmePath, join(targetDir, 'README.md')), 26 | copyFile(licensePath, join(targetDir, 'LICENSE')), 27 | ]) 28 | } 29 | 30 | execSync(command, { stdio: 'inherit' }) 31 | -------------------------------------------------------------------------------- /.github/workflows/autofix.yml: -------------------------------------------------------------------------------- 1 | name: autofix.ci 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: [main] 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | code: 13 | runs-on: ubuntu-latest 14 | timeout-minutes: 10 15 | 16 | steps: 17 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 18 | - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4 19 | - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6 20 | with: 21 | node-version: lts/* 22 | cache: pnpm 23 | 24 | - name: Install dependencies 25 | run: pnpm install 26 | 27 | - name: Check engine ranges, peer dependency ranges and installed versions 28 | run: pnpm installed-check --no-include-workspace-root --ignore-dev --fix 29 | 30 | - name: Build 31 | run: pnpm dev:prepare 32 | 33 | - name: Lint 34 | run: pnpm lint:fix 35 | 36 | - uses: autofix-ci/action@635ffb0c9798bd160680f18fd73371e355b85f27 37 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: publish 2 | 3 | permissions: 4 | id-token: write 5 | contents: write 6 | 7 | on: 8 | push: 9 | tags: 10 | - v* 11 | 12 | jobs: 13 | publish-npm: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 17 | with: 18 | fetch-depth: 0 19 | 20 | - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4 21 | - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6 22 | with: 23 | node-version: lts/* 24 | registry-url: https://registry.npmjs.org/ 25 | cache: pnpm 26 | 27 | - name: Install dependencies 28 | run: pnpm install 29 | 30 | - name: Build 31 | run: pnpm build 32 | 33 | - run: npx changelogithub --no-group 34 | continue-on-error: true 35 | env: 36 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 37 | 38 | - run: pnpm run publish:ci 39 | env: 40 | NODE_OPTIONS: --max-old-space-size=6144 41 | -------------------------------------------------------------------------------- /packages/core/src/router/render.ts: -------------------------------------------------------------------------------- 1 | import type { Component } from 'vue' 2 | import type { VRouteId, VRouteMatchedData, VRouteRenderComponent, VRoutesMap } from '../types' 3 | 4 | const renderListCache = new WeakMap>() 5 | 6 | export function createRenderList( 7 | data: VRouteMatchedData, 8 | routes: VRoutesMap, 9 | ): VRouteRenderComponent[] { 10 | let cacheForRoutes = renderListCache.get(routes) 11 | if (!cacheForRoutes) { 12 | cacheForRoutes = new Map() 13 | renderListCache.set(routes, cacheForRoutes) 14 | } 15 | 16 | const cached = cacheForRoutes.get(data.id) 17 | if (cached) { 18 | return cached 19 | } 20 | 21 | const depth = data.id[1] 22 | const list = Array.from({ length: depth }) 23 | let idx = depth 24 | let cursor = routes.get(data.id) 25 | while (cursor) { 26 | list[idx--] = cursor.component 27 | cursor = cursor.parentId !== undefined ? routes.get(cursor.parentId) : undefined 28 | } 29 | 30 | cacheForRoutes.set(data.id, list) 31 | return list 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Tankosin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@virou/core", 3 | "type": "module", 4 | "version": "1.1.1", 5 | "description": "Virtual router with multiple instance support for Vue", 6 | "author": "Tankosin", 7 | "license": "MIT", 8 | "homepage": "https://github.com/tankosinn/virou#readme", 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/tankosinn/virou.git", 12 | "directory": "packages/core" 13 | }, 14 | "bugs": { 15 | "url": "https://github.com/tankosinn/virou/issues" 16 | }, 17 | "keywords": [ 18 | "vue", 19 | "vue-router", 20 | "virtual-router", 21 | "router" 22 | ], 23 | "sideEffects": false, 24 | "exports": { 25 | "types": "./dist/index.d.ts", 26 | "default": "./dist/index.js" 27 | }, 28 | "main": "./dist/index.js", 29 | "types": "./dist/index.d.ts", 30 | "files": [ 31 | "dist" 32 | ], 33 | "scripts": { 34 | "build": "pnpm -w tsdown -F @virou/core" 35 | }, 36 | "peerDependencies": { 37 | "vue": "^3.5.0" 38 | }, 39 | "dependencies": { 40 | "rou3": "catalog:", 41 | "ufo": "catalog:" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /packages/core/test/_utils.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable ts/explicit-function-return-type */ 2 | import type { ComponentMountingOptions } from '@vue/test-utils' 3 | import { virou } from '@virou/core' 4 | import { mount } from '@vue/test-utils' 5 | import { defineComponent } from 'vue' 6 | 7 | export function useSetup(setup: () => V, options?: ComponentMountingOptions) { 8 | const Comp = defineComponent({ 9 | setup, 10 | render: () => null, 11 | }) 12 | 13 | return mount(Comp, options) 14 | } 15 | 16 | export function useSetupWithPlugin( 17 | setup: () => V, 18 | options?: ComponentMountingOptions, 19 | ) { 20 | return useSetup( 21 | setup, 22 | { 23 | ...options, 24 | global: { 25 | ...options?.global, 26 | plugins: [ 27 | ...(options?.global?.plugins ?? []), 28 | virou, 29 | ], 30 | }, 31 | }, 32 | ) 33 | } 34 | 35 | export function mountWithPlugin( 36 | component: C, 37 | options?: ComponentMountingOptions, 38 | ) { 39 | return mount(component, { 40 | ...options, 41 | global: { 42 | ...options?.global, 43 | plugins: [ 44 | ...(options?.global?.plugins ?? []), 45 | virou, 46 | ], 47 | }, 48 | }) 49 | } 50 | -------------------------------------------------------------------------------- /packages/nuxt/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@virou/nuxt", 3 | "type": "module", 4 | "version": "1.1.1", 5 | "description": "Virou Nuxt Module", 6 | "author": "Tankosin", 7 | "license": "MIT", 8 | "homepage": "https://github.com/tankosinn/virou#readme", 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/tankosinn/virou.git", 12 | "directory": "packages/nuxt" 13 | }, 14 | "bugs": { 15 | "url": "https://github.com/tankosinn/virou/issues" 16 | }, 17 | "keywords": [ 18 | "vue", 19 | "vue-router", 20 | "virtual-router", 21 | "router", 22 | "nuxt", 23 | "nuxt3", 24 | "nuxt-module" 25 | ], 26 | "sideEffects": false, 27 | "exports": { 28 | "types": "./dist/index.d.ts", 29 | "default": "./dist/index.js" 30 | }, 31 | "main": "./dist/index.js", 32 | "types": "./dist/index.d.ts", 33 | "files": [ 34 | "dist" 35 | ], 36 | "engines": { 37 | "node": ">=18.12.0" 38 | }, 39 | "scripts": { 40 | "build": "pnpm -w tsdown -F @virou/nuxt" 41 | }, 42 | "dependencies": { 43 | "@nuxt/kit": "catalog:", 44 | "@virou/core": "workspace:*", 45 | "defu": "catalog:" 46 | }, 47 | "devDependencies": { 48 | "@nuxt/schema": "catalog:", 49 | "nuxt": "catalog:" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: [main] 7 | 8 | jobs: 9 | lint: 10 | runs-on: ubuntu-latest 11 | timeout-minutes: 10 12 | 13 | steps: 14 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 15 | - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4 16 | - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6 17 | with: 18 | node-version: lts/* 19 | cache: pnpm 20 | 21 | - name: Install dependencies 22 | run: pnpm install 23 | 24 | - name: Lint 25 | run: pnpm lint 26 | 27 | test: 28 | runs-on: ubuntu-latest 29 | 30 | steps: 31 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 32 | - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4 33 | - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6 34 | with: 35 | node-version: lts/* 36 | cache: pnpm 37 | 38 | - name: Install dependencies 39 | run: pnpm install 40 | 41 | - name: Build 42 | run: pnpm dev:prepare 43 | 44 | - name: Typecheck 45 | run: pnpm typecheck 46 | 47 | - name: Test 48 | run: pnpm test 49 | -------------------------------------------------------------------------------- /packages/core/test/plugin.test.ts: -------------------------------------------------------------------------------- 1 | import type { App } from 'vue' 2 | import { virou } from '@virou/core' 3 | import { beforeEach, describe, expect, it } from 'vitest' 4 | import { createApp } from 'vue' 5 | 6 | describe('plugin', () => { 7 | let app: App 8 | 9 | beforeEach(() => { 10 | app = createApp({}) 11 | }) 12 | 13 | it('should install the plugin', () => { 14 | app.use(virou) 15 | 16 | const map = app.config.globalProperties.$virou 17 | expect(map).toBeDefined() 18 | expect(map).toBeInstanceOf(Map) 19 | expect(map.size).toBe(0) 20 | }) 21 | 22 | it('should pre-register routers from options with isGlobal=true', () => { 23 | const routes = [{ path: '/foo', component: { name: 'Foo', render: () => null } }] 24 | 25 | app.use(virou, { 26 | routers: { 27 | foo: { 28 | routes, 29 | options: { 30 | initialPath: '/foo', 31 | }, 32 | }, 33 | }, 34 | }) 35 | 36 | const map = app.config.globalProperties.$virou 37 | const router = map.get('foo') 38 | 39 | expect(router).toBeDefined() 40 | expect(router?.isGlobal).toBe(true) 41 | 42 | const paths = [...router!.routes.keys()].map(([path]) => path) 43 | expect(paths).toContain('/foo') 44 | expect(router?.activePath.value).toBe('/foo') 45 | }) 46 | }) 47 | -------------------------------------------------------------------------------- /packages/nuxt/src/index.ts: -------------------------------------------------------------------------------- 1 | import type { VirouPluginOptions } from '@virou/core' 2 | import { dirname, resolve } from 'node:path' 3 | import { fileURLToPath } from 'node:url' 4 | import { addComponent, addImports, defineNuxtModule } from '@nuxt/kit' 5 | import { defu } from 'defu' 6 | 7 | const _dirname = dirname(fileURLToPath(import.meta.url)) 8 | 9 | const functions = [ 10 | 'useVRouter', 11 | ] 12 | 13 | const components = [ 14 | 'VRouterView', 15 | ] 16 | 17 | export interface ModuleOptions extends VirouPluginOptions {} 18 | 19 | export default defineNuxtModule({ 20 | meta: { 21 | name: '@virou/nuxt', 22 | configKey: 'virou', 23 | }, 24 | setup(options, nuxt) { 25 | nuxt.options.runtimeConfig.public.virou = defu(nuxt.options.runtimeConfig.public.virou, options) 26 | 27 | const pluginPath = resolve(_dirname, './plugin.mjs') 28 | nuxt.options.plugins = nuxt.options.plugins ?? [] 29 | nuxt.options.plugins.push(pluginPath) 30 | nuxt.options.build.transpile.push(pluginPath) 31 | 32 | functions.forEach((name) => { 33 | addImports({ name, as: name, from: '@virou/core', priority: -1 }) 34 | }) 35 | 36 | components.forEach((name) => { 37 | addComponent({ name, export: name, filePath: '@virou/core', priority: -1 }) 38 | }) 39 | }, 40 | }) 41 | 42 | declare module '@nuxt/schema' { 43 | interface PublicRuntimeConfig { 44 | virou?: ModuleOptions 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /packages/core/src/router/register.ts: -------------------------------------------------------------------------------- 1 | import type { RouterContext } from 'rou3' 2 | import type { VRouteId, VRouteMatchedData, VRouteRaw, VRoutesMap } from '../types' 3 | import { addRoute } from 'rou3' 4 | import { joinURL } from 'ufo' 5 | import { normalizeComponent } from '../utils' 6 | 7 | interface StackItem { 8 | route: VRouteRaw 9 | parentId?: VRouteId 10 | } 11 | 12 | export function registerRoutes( 13 | ctx: RouterContext, 14 | routes: VRouteRaw[], 15 | registry: VRoutesMap, 16 | ): void { 17 | const stack: StackItem[] = [] 18 | for (let i = routes.length - 1; i >= 0; i--) { 19 | stack.push({ route: routes[i] }) 20 | } 21 | 22 | while (stack.length > 0) { 23 | const { route, parentId } = stack.pop()! 24 | const { path, meta, component, children } = route 25 | 26 | const parentPath = parentId?.[0] ?? '/' 27 | const fullPath = joinURL(parentPath, path) 28 | const depth = (parentId?.[1] ?? -1) + 1 29 | const id: VRouteId = Object.freeze([fullPath, depth]) 30 | 31 | registry.set(id, { 32 | meta, 33 | component: normalizeComponent(component), 34 | parentId, 35 | }) 36 | 37 | let isShadowed = false 38 | if (children && children.length > 0) { 39 | isShadowed = children.some(c => c.path === '' || c.path === '/') 40 | 41 | for (let i = children.length - 1; i >= 0; i--) { 42 | stack.push({ route: children[i], parentId: id }) 43 | } 44 | } 45 | 46 | if (!isShadowed) { 47 | addRoute(ctx, 'GET', fullPath, { id, meta }) 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | // Disable the default formatter, use eslint instead 3 | "prettier.enable": false, 4 | "editor.formatOnSave": false, 5 | 6 | // Auto fix 7 | "editor.codeActionsOnSave": { 8 | "source.fixAll.eslint": "explicit", 9 | "source.organizeImports": "never" 10 | }, 11 | 12 | // Silent the stylistic rules in your IDE, but still auto fix them 13 | "eslint.rules.customizations": [ 14 | { "rule": "style/*", "severity": "off", "fixable": true }, 15 | { "rule": "format/*", "severity": "off", "fixable": true }, 16 | { "rule": "*-indent", "severity": "off", "fixable": true }, 17 | { "rule": "*-spacing", "severity": "off", "fixable": true }, 18 | { "rule": "*-spaces", "severity": "off", "fixable": true }, 19 | { "rule": "*-order", "severity": "off", "fixable": true }, 20 | { "rule": "*-dangle", "severity": "off", "fixable": true }, 21 | { "rule": "*-newline", "severity": "off", "fixable": true }, 22 | { "rule": "*quotes", "severity": "off", "fixable": true }, 23 | { "rule": "*semi", "severity": "off", "fixable": true } 24 | ], 25 | 26 | // Enable eslint for all supported languages 27 | "eslint.validate": [ 28 | "javascript", 29 | "javascriptreact", 30 | "typescript", 31 | "typescriptreact", 32 | "vue", 33 | "html", 34 | "markdown", 35 | "json", 36 | "jsonc", 37 | "yaml", 38 | "toml", 39 | "xml", 40 | "gql", 41 | "graphql", 42 | "astro", 43 | "svelte", 44 | "css", 45 | "less", 46 | "scss", 47 | "pcss", 48 | "postcss" 49 | ] 50 | } 51 | -------------------------------------------------------------------------------- /playgrounds/vite/src/components/NavigationMenu.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 25 | 26 | 77 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@virou/monorepo", 3 | "type": "module", 4 | "version": "1.1.1", 5 | "private": true, 6 | "packageManager": "pnpm@10.26.0", 7 | "description": "Virtual router with multiple instance support for Vue", 8 | "author": "Tankosin", 9 | "license": "MIT", 10 | "scripts": { 11 | "build": "tsdown", 12 | "dev": "pnpm play", 13 | "dev:prepare": "pnpm build", 14 | "lint": "eslint --cache .", 15 | "lint:fix": "pnpm lint --fix", 16 | "release": "bumpp -- \"./package.json\" -- \"./packages/*/package.json\" --all", 17 | "publish:ci": "tsx scripts/publish.ts", 18 | "play": "pnpm --filter './playgrounds/vite' dev", 19 | "play:build": "pnpm --filter './playgrounds/vite' build", 20 | "play:preview": "pnpm --filter './playgrounds/vite' preview", 21 | "test": "vitest run", 22 | "test:cov": "vitest run --coverage", 23 | "test:unit": "vitest run --project unit", 24 | "typecheck": "vue-tsc", 25 | "prepare": "simple-git-hooks" 26 | }, 27 | "devDependencies": { 28 | "@antfu/eslint-config": "catalog:", 29 | "@vitejs/plugin-vue": "catalog:", 30 | "@vitest/coverage-v8": "catalog:", 31 | "@vue/test-utils": "catalog:", 32 | "bumpp": "catalog:", 33 | "eslint": "catalog:", 34 | "happy-dom": "catalog:", 35 | "installed-check": "catalog:", 36 | "lint-staged": "catalog:", 37 | "simple-git-hooks": "catalog:", 38 | "tsdown": "catalog:", 39 | "tsx": "catalog:", 40 | "typescript": "catalog:", 41 | "vitest": "catalog:", 42 | "vue-tsc": "catalog:" 43 | }, 44 | "simple-git-hooks": { 45 | "pre-commit": "pnpm lint-staged" 46 | }, 47 | "lint-staged": { 48 | "*.{js,ts,tsx,vue,md,json}": ["eslint --cache --fix"] 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /packages/core/src/types.ts: -------------------------------------------------------------------------------- 1 | import type { RouterContext } from 'rou3' 2 | import type { Component, DefineComponent, ShallowRef } from 'vue' 3 | 4 | type Lazy = () => Promise 5 | 6 | export type VRouteComponent = Component | DefineComponent 7 | export type VRouteLazyComponent = Lazy 8 | export type VRouteRenderComponent = VRouteComponent | VRouteLazyComponent 9 | 10 | export type VRouteMeta = Record 11 | 12 | export interface VRouteRaw { 13 | path: string 14 | component: VRouteRenderComponent 15 | meta?: VRouteMeta 16 | children?: VRouteRaw[] 17 | } 18 | 19 | export type VRouteId = Readonly<[path: string, depth: number]> 20 | 21 | export interface VRouteMatchedData { 22 | id: VRouteId 23 | meta?: VRouteMeta 24 | } 25 | 26 | export interface VRouteNormalized extends Omit { 27 | parentId?: VRouteId 28 | } 29 | 30 | export type VRoutesMap = Map 31 | 32 | export interface VRoute { 33 | fullPath: string 34 | meta?: VRouteMeta 35 | params?: Record 36 | path: string 37 | search: string 38 | hash: string 39 | '~renderList': Component[] | null 40 | } 41 | 42 | export interface VRouterData { 43 | context: RouterContext 44 | routes: VRoutesMap 45 | activePath: ShallowRef 46 | route: ShallowRef 47 | isGlobal: boolean 48 | '~deps': number 49 | '~dispose': () => void 50 | } 51 | 52 | export interface VRouterOptions { 53 | initialPath?: string 54 | isGlobal?: boolean 55 | } 56 | 57 | export interface VRouter { 58 | route: VRouterData['route'] 59 | router: { 60 | addRoute: (route: VRouteRaw) => void 61 | replace: (path: string) => void 62 | '~depthKey': symbol 63 | } 64 | } 65 | 66 | export interface VirouPluginOptions { 67 | routers?: Record 70 | }> 71 | } 72 | -------------------------------------------------------------------------------- /packages/core/src/router/create.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | VRoute, 3 | VRouteId, 4 | VRouteMatchedData, 5 | VRouteRaw, 6 | VRouterData, 7 | VRouteRenderComponent, 8 | VRouterOptions, 9 | VRoutesMap, 10 | } from '../types' 11 | import { createRouter, findRoute } from 'rou3' 12 | import { parseURL } from 'ufo' 13 | import { shallowRef, watchEffect } from 'vue' 14 | import { registerRoutes } from './register' 15 | import { createRenderList } from './render' 16 | 17 | export function createVRouter(routes: VRouteRaw[], options?: VRouterOptions): VRouterData { 18 | const context = createRouter() 19 | const routeRegistry: VRoutesMap = new Map() 20 | 21 | registerRoutes(context, routes, routeRegistry) 22 | 23 | let lastMatchedId: VRouteId | undefined 24 | let lastRenderList: VRouteRenderComponent[] | null = null 25 | 26 | const activePath = shallowRef(options?.initialPath ?? '/') 27 | 28 | const snapshot = (): VRoute => { 29 | const matchedRoute = findRoute(context, 'GET', activePath.value) 30 | if (matchedRoute) { 31 | if (matchedRoute.data.id !== lastMatchedId) { 32 | lastRenderList = createRenderList(matchedRoute.data, routeRegistry) 33 | lastMatchedId = matchedRoute.data.id 34 | } 35 | } 36 | else { 37 | lastMatchedId = undefined 38 | lastRenderList = null 39 | } 40 | 41 | const { pathname, hash, search } = parseURL(activePath.value) 42 | 43 | return { 44 | fullPath: activePath.value, 45 | path: pathname, 46 | search, 47 | hash, 48 | meta: matchedRoute?.data.meta, 49 | params: matchedRoute?.params, 50 | '~renderList': lastRenderList, 51 | } 52 | } 53 | 54 | const route = shallowRef(snapshot()) 55 | 56 | const unwatch = watchEffect(() => { 57 | route.value = snapshot() 58 | }) 59 | 60 | return { 61 | context, 62 | routes: routeRegistry, 63 | activePath, 64 | route, 65 | isGlobal: options?.isGlobal ?? false, 66 | '~deps': 0, 67 | '~dispose': unwatch, 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /packages/core/src/components/VRouterView.ts: -------------------------------------------------------------------------------- 1 | import type { PropType, SlotsType, VNodeChild } from 'vue' 2 | import type { VRoute } from '../types' 3 | import { 4 | defineComponent, 5 | h, 6 | inject, 7 | KeepAlive, 8 | provide, 9 | Suspense, 10 | } from 'vue' 11 | import { useVRouter } from '../composables' 12 | import { virouSymbol } from '../constants' 13 | 14 | export const VRouterView = defineComponent({ 15 | name: 'VRouterView', 16 | inheritAttrs: false, 17 | props: { 18 | routerKey: String, 19 | keepAlive: { type: Boolean, default: false }, 20 | viewKey: { type: [String, Function] as PropType string)> }, 21 | }, 22 | slots: Object as SlotsType<{ 23 | default: (payload: { Component: VNodeChild, route: VRoute }) => VNodeChild 24 | fallback: (payload: { route: VRoute }) => VNodeChild 25 | }>, 26 | setup(props, { slots, attrs }) { 27 | const key = props.routerKey ?? inject(virouSymbol) 28 | if (key === undefined) { 29 | throw new Error('[virou] [VRouterView] routerKey is required') 30 | } 31 | 32 | const { route, router } = useVRouter(key) 33 | 34 | const depth = inject(router['~depthKey'], 0) 35 | provide(router['~depthKey'], depth + 1) 36 | 37 | return () => { 38 | const component = route.value['~renderList']?.[depth] 39 | if (!component) { 40 | return slots.default?.({ Component: null, route: route.value }) ?? null 41 | } 42 | 43 | const vnodeKey = typeof props.viewKey === 'function' 44 | ? props.viewKey(route.value, key) 45 | : props.viewKey ?? `${key}-${depth}-${route.value.path}` 46 | 47 | const suspenseVNode = h( 48 | Suspense, 49 | null, 50 | { 51 | default: () => h(component, { key: vnodeKey, ...attrs }), 52 | fallback: () => slots.fallback?.({ route: route.value }) ?? null, 53 | }, 54 | ) 55 | 56 | const vnode = props.keepAlive 57 | ? h(KeepAlive, null, { default: () => suspenseVNode }) 58 | : suspenseVNode 59 | 60 | return slots.default?.({ Component: vnode, route: route.value }) ?? vnode 61 | } 62 | }, 63 | }) 64 | -------------------------------------------------------------------------------- /packages/core/src/composables/useVRouter.ts: -------------------------------------------------------------------------------- 1 | import type { VRouter, VRouteRaw, VRouterOptions } from '../types' 2 | import { getCurrentInstance, inject, onScopeDispose, provide, useId } from 'vue' 3 | import { virouSymbol } from '../constants' 4 | import { createVRouter, registerRoutes } from '../router' 5 | 6 | export function useVRouter(routes?: VRouteRaw[], options?: VRouterOptions): VRouter 7 | export function useVRouter(key?: string, routes?: VRouteRaw[], options?: VRouterOptions): VRouter 8 | export function useVRouter(...args: any[]): VRouter { 9 | if (typeof args[0] !== 'string') { 10 | args.unshift(inject(virouSymbol, useId())) 11 | } 12 | 13 | const [key, routes = [], options = {}] = args as [string, VRouteRaw[], VRouterOptions] 14 | 15 | if (!key || typeof key !== 'string') { 16 | throw new TypeError(`[virou] [useVRouter] key must be a string: ${key}`) 17 | } 18 | 19 | provide(virouSymbol, key) 20 | 21 | const vm = getCurrentInstance() 22 | if (!vm) { 23 | throw new Error('[virou] [useVRouter] useVRouter must be called in setup()') 24 | } 25 | 26 | const virou = vm.proxy?.$virou 27 | if (!virou) { 28 | throw new Error('[virou] [useVRouter] virou plugin not installed') 29 | } 30 | 31 | if (routes.length) { 32 | if (virou.get(key)) { 33 | throw new Error(`[virou] [useVRouter] router with key "${key}" already exists`) 34 | } 35 | 36 | virou.set(key, createVRouter(routes, options)) 37 | } 38 | 39 | const router = virou.get(key) 40 | if (!router) { 41 | throw new Error(`[virou] [useVRouter] router with key "${key}" not found`) 42 | } 43 | 44 | router['~deps']++ 45 | onScopeDispose(() => { 46 | router['~deps']-- 47 | 48 | if (router['~deps'] === 0 && !router.isGlobal) { 49 | router['~dispose']() 50 | virou.delete(key) 51 | } 52 | }) 53 | 54 | return { 55 | route: router.route, 56 | router: { 57 | addRoute: (route: VRouteRaw) => { 58 | registerRoutes(router.context, [route], router.routes) 59 | }, 60 | replace: (path: string) => { 61 | router.activePath.value = path 62 | }, 63 | '~depthKey': Symbol.for(key), 64 | }, 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /playgrounds/vite/src/App.vue: -------------------------------------------------------------------------------- 1 | 95 | 96 | 125 | 126 | 162 | 163 | 173 | -------------------------------------------------------------------------------- /packages/core/test/composables.test.ts: -------------------------------------------------------------------------------- 1 | import type { VRoute, VRouteRaw } from '@virou/core' 2 | import { useVRouter, virou } from '@virou/core' 3 | import { mount } from '@vue/test-utils' 4 | import { afterEach, describe, expect, it, vi } from 'vitest' 5 | import { defineComponent, h } from 'vue' 6 | import { useSetup, useSetupWithPlugin } from './_utils' 7 | 8 | describe('composables:useVRouter', () => { 9 | afterEach(() => { 10 | vi.resetModules() 11 | vi.clearAllMocks() 12 | }) 13 | 14 | it('should throw error when plugin is not installed', () => { 15 | expect(() => useSetup(() => useVRouter())).toThrowError('[virou] [useVRouter] virou plugin not installed') 16 | }) 17 | 18 | it('should throw error when key is not a non-empty string', () => { 19 | expect(() => useSetupWithPlugin(() => useVRouter(''))).toThrowError('[virou] [useVRouter] key must be a string: ') 20 | }) 21 | 22 | it('should throw error when useVRouter is not called in setup()', () => { 23 | expect(() => useVRouter('foo')).toThrowError('[virou] [useVRouter] useVRouter must be called in setup()') 24 | }) 25 | 26 | it('should throw error when router with key already exists', () => { 27 | const routes: VRouteRaw[] = [{ path: '/', component: () => null }] 28 | 29 | expect(() => useSetupWithPlugin(() => { 30 | useVRouter('foo', routes) 31 | useVRouter('foo', routes) 32 | })).toThrowError('[virou] [useVRouter] router with key "foo" already exists') 33 | }) 34 | 35 | it('should throw error when router with key does not exist', () => { 36 | expect(() => useSetupWithPlugin(() => useVRouter('foo'))).toThrowError('[virou] [useVRouter] router with key "foo" not found') 37 | }) 38 | 39 | it('should create router with key', () => { 40 | const routes: VRouteRaw[] = [{ path: '/', component: () => null }] 41 | 42 | const wrapper = useSetupWithPlugin(() => { 43 | useVRouter('foo', routes) 44 | }) 45 | 46 | const virou = wrapper.vm.$virou 47 | 48 | expect(virou.has('foo')).toBe(true) 49 | }) 50 | 51 | it('should create router without key', () => { 52 | const routes: VRouteRaw[] = [{ path: '/', component: () => null }] 53 | 54 | vi.mock(import('vue'), async (importOriginal) => { 55 | const actual = await importOriginal() 56 | return { 57 | ...actual, 58 | useId: () => 'foo', 59 | } 60 | }) 61 | 62 | const wrapper = useSetupWithPlugin(() => { 63 | useVRouter(routes) 64 | }) 65 | 66 | const virou = wrapper.vm.$virou 67 | 68 | expect(virou.has('foo')).toBe(true) 69 | }) 70 | 71 | it('should add route', () => { 72 | const routes: VRouteRaw[] = [{ path: '/', component: () => null }] 73 | 74 | const wrapper = useSetupWithPlugin(() => { 75 | const { router } = useVRouter('foo', routes) 76 | 77 | router.addRoute({ path: '/about', component: () => null }) 78 | }) 79 | 80 | const router = wrapper.vm.$virou.get('foo') 81 | 82 | expect(router?.routes.size).toBe(2) 83 | }) 84 | 85 | it('should replace active path', () => { 86 | const routes: VRouteRaw[] = [{ path: '/', component: () => null }] 87 | 88 | const wrapper = useSetupWithPlugin(() => { 89 | const { router } = useVRouter('foo', routes) 90 | 91 | router.replace('/about') 92 | }) 93 | 94 | const router = wrapper.vm.$virou.get('foo') 95 | 96 | expect(router?.activePath.value).toBe('/about') 97 | }) 98 | 99 | it('should dispose a non-global router after have no dependency', () => { 100 | const routes: VRouteRaw[] = [{ path: '/', component: () => null }] 101 | 102 | const wrapper = useSetupWithPlugin(() => { 103 | useVRouter('temp', routes) 104 | }) 105 | 106 | const virou = wrapper.vm.$virou 107 | expect(virou.has('temp')).toBe(true) 108 | 109 | wrapper.unmount() 110 | expect(virou.has('temp')).toBe(false) 111 | }) 112 | 113 | it('should not dispose a global router after have no dependency', () => { 114 | const routes: VRouteRaw[] = [{ path: '/', component: () => null }] 115 | 116 | const wrapper = useSetupWithPlugin(() => { 117 | useVRouter('temp', routes, { isGlobal: true }) 118 | }) 119 | 120 | const virou = wrapper.vm.$virou 121 | expect(virou.has('temp')).toBe(true) 122 | 123 | wrapper.unmount() 124 | expect(virou.has('temp')).toBe(true) 125 | }) 126 | 127 | it('should inject router key from parent component to child component router', () => { 128 | const routes: VRouteRaw[] = [{ path: '/shared', component: () => null }] 129 | 130 | let childRoute: VRoute = {} as VRoute 131 | let parentRoute: VRoute = {} as VRoute 132 | 133 | const Child = defineComponent({ 134 | setup() { 135 | const { route } = useVRouter() 136 | childRoute = route.value 137 | return () => null 138 | }, 139 | }) 140 | 141 | const Parent = defineComponent({ 142 | components: { Child }, 143 | setup() { 144 | const { route } = useVRouter('shared', routes, { initialPath: '/shared' }) 145 | parentRoute = route.value 146 | return () => h(Child) 147 | }, 148 | }) 149 | 150 | expect(() => mount(Parent, { global: { plugins: [virou] } })).not.toThrowError() 151 | expect(childRoute).toBe(parentRoute) 152 | }) 153 | }) 154 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🧭 Virou 2 | 3 |

4 | Version 5 | Downloads 6 | Bundle Size 7 | License 8 |

9 | 10 | Virou is a high-performance, lightweight virtual router for Vue with dynamic routing capabilities. 11 | 12 | > Perfect for modals, wizards, embeddable widgets, or any scenario requiring routing without altering the browser's URL or history. 13 | 14 | ## ✨ Features 15 | 16 | - 🪄 **Dynamic Virtual Routing**: Navigate without altering the browser's URL or history 17 | - 🍂 **Multiple Router Instances**: Manage independent routing contexts within the same app 18 | - 🪆 **Nested Routing**: Seamlessly handle complex, nested routes 19 | - 🦾 **Type-Safe**: Written in [TypeScript](https://www.typescriptlang.org/) 20 | - ⚡ **SSR-Friendly**: Compatible with server-side rendering 21 | 22 | ## 🐣 Usage 23 | ```vue 24 | 42 | 43 | 47 | ``` 48 | 49 | ## 📦 Installation 50 | 51 | Install Virou with your package manager: 52 | 53 | ```bash 54 | pnpm add @virou/core 55 | ``` 56 | 57 | Register the `virou` plugin in your Vue app: 58 | 59 | ```typescript 60 | import { virou } from '@virou/core' 61 | import { createApp } from 'vue' 62 | import App from './App.vue' 63 | 64 | createApp(App) 65 | .use(virou) 66 | .mount('#app') 67 | ``` 68 | 69 | > ⚠️ Virou doesn’t globally register any components (including `VRouterView`); it only adds a `$virou` global property to store router instances in a `Map`, which are automatically removed when no longer in use. 70 | 71 | ### 🧩 Using with Nuxt 72 | 73 | Install Virou Nuxt module with your package manager: 74 | 75 | ```bash 76 | pnpm add @virou/nuxt 77 | ``` 78 | 79 | Add the module to your Nuxt configuration: 80 | 81 | ```typescript 82 | // nuxt.config.ts 83 | export default defineNuxtConfig({ 84 | modules: [ 85 | '@virou/nuxt', 86 | ], 87 | }) 88 | ``` 89 | 90 | ## 🧱 Essentials 91 | 92 | ### 🌿 Routes 93 | 94 | Declare your routes as an array of objects with required `path` and `component`, and optional `meta` and `children` properties. 95 | 96 | ```typescript 97 | const routes: VRouterRaw[] = [ 98 | { 99 | path: '/', // static path 100 | component: Home, 101 | }, 102 | { 103 | path: '/user/:id', // dynamic path with parameter 104 | component: () => import('./views/User.vue'), 105 | meta: { 106 | foo: 'bar', 107 | } 108 | }, 109 | { 110 | path: '/**:notFound', // Named wildcard path 111 | component: defineAsyncComponent(() => import('./views/NotFound.vue')), 112 | } 113 | ] 114 | ``` 115 | 116 | **Props**: 117 | - `path`: the URL pattern to match. Supports: 118 | - Static ("/about") for exact matches 119 | - Dynamic ("/user/:id") for named parameters 120 | - Wildcard ("/**") for catch-all segments 121 | - Named wildcard ("/**:notFound") for catch-all with a name 122 | - `component`: the Vue component to render when this route matches. Can be synchronous or an async loader. 123 | - `meta`: metadata for the route. 124 | - `children`: an array of child routes. 125 | 126 | > Virou uses [rou3](https://github.com/h3js/rou3) under the hood for create router context and route matching. 127 | 128 | ### 🪆 Nested Routes 129 | 130 | To define nested routes, add a children array to a route record. Child path values are relative to their parent (leading `/` is ignored). 131 | 132 | ```typescript 133 | const routes: VRouterRaw[] = [ 134 | // ... 135 | { 136 | path: '/user/:id', 137 | component: User, 138 | children: [ 139 | { 140 | path: '', // /user/:id -> default child route 141 | component: UserProfile, 142 | }, 143 | { 144 | path: '/settings', // /user/:id/settings 145 | component: UserSettings, 146 | children: [ 147 | { 148 | path: '/', // /user/:id/settings -> deep default child route 149 | component: UserSettingsGeneral, 150 | }, 151 | { 152 | path: '/notifications', // /user/:id/settings/notifications 153 | component: UserSettingsNotifications, 154 | }, 155 | ], 156 | }, 157 | ] 158 | }, 159 | // ... 160 | ] 161 | ``` 162 | 163 | ### 🌳 Router 164 | 165 | Create (or access) a virtual router instance with `useVRouter` composable. 166 | 167 | ```typescript 168 | const { router, route } = useVRouter('my-wizard', routes) 169 | ``` 170 | 171 | > `useVRouter` must be called inside `setup()`. 172 | 173 | **Params:** 174 | - `key`: a unique key for the router instance. If you do not provide a key, Virou will generate one via `useId()`. 175 | - `routes`: an array of route objects. 176 | - `options`: 177 | - `initialPath`: the path to render on initialization (defaults to `/`). 178 | 179 | **Returns:** 180 | - `route`: a Vue shallow ref that contains the current route object. 181 | ```typescript 182 | export interface VRoute { 183 | fullPath: string 184 | path: string 185 | search: string 186 | hash: string 187 | meta?: Record 188 | params?: Record 189 | '~renderList': Component[] | null 190 | } 191 | ``` 192 | - `router`: 193 | - `replace(path: string): void`: navigate to a new path. 194 | - `addRoute(route: VRouteRaw): void`: add a route at runtime. 195 | 196 | #### 🍃 Multiple Router Instances 197 | 198 | Virou allows you to create multiple independent router instances within the same app. 199 | 200 | ```vue 201 | 228 | 229 | 238 | ``` 239 | 240 | ## 📺 Rendering 241 | 242 | `` mounts the matched component at its current nesting depth. 243 | 244 | ```vue 245 | 265 | ``` 266 | 267 | **Props:** 268 | - `routerKey`: key of the router instance to render. If not provided, uses the nearest router instance. 269 | - `keepAlive`: wraps the rendered component in `` when set to true, preserving its state across navigations. 270 | - `viewKey`: accepts either a string or a function `(route, key) => string` to compute the vnode key for the rendered component. 271 | 272 | **Slots:** 273 | - `default`: slot receives `{ Component, route }` so you can wrap or decorate the active component. 274 | - `fallback`: receives `{ route }` and is displayed inside `` while an async component is resolving. 275 | 276 | > Virou wraps components in `` by default. To combine `` with other components, see the [Vue docs](https://vuejs.org/guide/built-ins/suspense#combining-with-other-components). 277 | 278 | ## 🛠️ Advanced 279 | 280 | ### 🌐 Global Routers 281 | 282 | By default, routers created with `useVRouter(key, routes)` are disposable—they unregister themselves automatically once no components reference them. 283 | 284 | To keep a router alive for your app’s entire lifecycle, register it as a global router. 285 | 286 | #### Plugin-Registered Globals 287 | 288 | Defined routers in the `virou` plugin options are registered as global routers: 289 | 290 | ```ts 291 | createApp(App) 292 | .use(virou, { 293 | routers: { 294 | 'embedded-widget-app': { 295 | routes: [ 296 | { path: '/chat', component: () => import('./views/Chat.vue') }, 297 | { path: '/settings', component: () => import('./views/Settings.vue') }, 298 | ], 299 | options: { initialPath: '/chat' } 300 | }, 301 | // add more global routers here... 302 | } 303 | }) 304 | .mount('#app') 305 | ``` 306 | 307 | Later: 308 | 309 | ```ts 310 | const { router, route } = useVRouter('embedded-widget-app') 311 | ``` 312 | 313 | #### Runtime-Registered Globals 314 | 315 | You may also mark a router as global at runtime by passing the `isGlobal` option: 316 | 317 | ```ts 318 | useVRouter(routes, { isGlobal: true }) 319 | ``` 320 | 321 | That router will stay registered even after components that use it unmount. 322 | 323 | ### 🧪 Create Standalone Virtual Router 324 | 325 | You can create a standalone virtual router with `createVRouter`: 326 | 327 | ```ts 328 | export function useCustomRouter() { 329 | const { router, route } = createVRouter(routes) 330 | 331 | // Custom logic here... 332 | } 333 | ``` 334 | 335 | ## 📝 License 336 | 337 | [MIT License](https://github.com/tankosinn/virou/blob/main/LICENSE) 338 | -------------------------------------------------------------------------------- /packages/core/test/router.test.ts: -------------------------------------------------------------------------------- 1 | import type { VRouteId, VRouteMatchedData, VRouteNormalized, VRouteRaw, VRoutesMap } from '@virou/core' 2 | import type { Component } from 'vue' 3 | import { createVRouter } from '@virou/core' 4 | import { createRouter, findRoute } from 'rou3' 5 | import * as rou3 from 'rou3' 6 | import { afterEach, describe, expect, it, vi } from 'vitest' 7 | import { defineAsyncComponent, nextTick } from 'vue' 8 | import { registerRoutes } from '../src/router/register' 9 | import { createRenderList } from '../src/router/render' 10 | import * as render from '../src/router/render' 11 | 12 | vi.mock('rou3', async (importOriginal) => { 13 | const actual = await importOriginal() 14 | return { 15 | ...actual, 16 | addRoute: vi.fn(actual.addRoute), 17 | } 18 | }) 19 | 20 | afterEach(() => { 21 | vi.clearAllMocks() 22 | }) 23 | 24 | function getRouteEntry(registry: VRoutesMap, path: string, depth: number): [VRouteId, VRouteNormalized] { 25 | const routeEntry = Array.from(registry.entries()).find( 26 | ([id]) => id[0] === path && id[1] === depth, 27 | ) 28 | if (!routeEntry) { 29 | throw new Error(`Route not found: ${path} at depth ${depth}`) 30 | } 31 | return routeEntry 32 | } 33 | 34 | describe('router:create', () => { 35 | it('should create router', () => { 36 | const routes: VRouteRaw[] = [{ path: '/', component: { name: 'Home' }, meta: { foo: 'bar' } }] 37 | 38 | const routerData = createVRouter(routes) 39 | 40 | expect(routerData.routes.size).toBe(1) 41 | expect(routerData.activePath.value).toBe('/') 42 | 43 | const route = routerData.route.value 44 | 45 | expect(route.path).toBe('/') 46 | expect(route.fullPath).toBe('/') 47 | expect(route.params).toBeUndefined() 48 | expect(route.search).toBe('') 49 | expect(route.hash).toBe('') 50 | 51 | expect(route.meta).toEqual({ foo: 'bar' }) 52 | 53 | expect(route['~renderList']).toEqual([{ name: 'Home' }]) 54 | 55 | expect(routerData.isGlobal).toBe(false) 56 | expect(routerData['~deps']).toBe(0) 57 | }) 58 | 59 | it('should create global router', () => { 60 | const routes: VRouteRaw[] = [{ path: '/', component: () => null }] 61 | 62 | const routerData = createVRouter(routes, { 63 | isGlobal: true, 64 | }) 65 | 66 | expect(routerData.isGlobal).toBe(true) 67 | expect(routerData['~deps']).toBe(0) 68 | }) 69 | 70 | it('should create router with initial path', () => { 71 | const routes: VRouteRaw[] = [ 72 | { 73 | path: '/', 74 | component: { name: 'Home' }, 75 | meta: { foo: 'bar' }, 76 | }, 77 | { 78 | path: '/about', 79 | component: { name: 'About' }, 80 | meta: { foo: 'baz' }, 81 | }, 82 | ] 83 | 84 | const routerData = createVRouter(routes, { 85 | initialPath: '/about', 86 | }) 87 | 88 | expect(routerData.activePath.value).toBe('/about') 89 | 90 | const route = routerData.route.value 91 | 92 | expect(route.path).toBe('/about') 93 | expect(route.fullPath).toBe('/about') 94 | }) 95 | 96 | it('should reuse render list when route id stays the same (params/query/hash changes)', async () => { 97 | const routes: VRouteRaw[] = [ 98 | { path: '/users/:id', component: { name: 'User', render: () => null } }, 99 | ] 100 | 101 | const renderSpy = vi.spyOn(render, 'createRenderList') 102 | 103 | const routerData = createVRouter(routes, { initialPath: '/users/1?foo=bar#one' }) 104 | const initialRenderList = routerData.route.value['~renderList'] 105 | 106 | renderSpy.mockClear() 107 | 108 | routerData.activePath.value = '/users/2?foo=baz#two' 109 | await nextTick() 110 | 111 | expect(renderSpy).not.toHaveBeenCalled() 112 | expect(routerData.route.value['~renderList']).toBe(initialRenderList) 113 | }) 114 | 115 | it('should recreate render list when route id changes', async () => { 116 | const routes: VRouteRaw[] = [ 117 | { path: '/', component: { name: 'Home', render: () => null } }, 118 | { path: '/about', component: { name: 'About', render: () => null } }, 119 | ] 120 | 121 | const renderSpy = vi.spyOn(render, 'createRenderList') 122 | 123 | const routerData = createVRouter(routes, { initialPath: '/' }) 124 | const initialRenderList = routerData.route.value['~renderList'] 125 | 126 | renderSpy.mockClear() 127 | 128 | routerData.activePath.value = '/about' 129 | await nextTick() 130 | 131 | expect(renderSpy).toHaveBeenCalledTimes(1) 132 | expect(routerData.route.value['~renderList']).not.toBe(initialRenderList) 133 | }) 134 | }) 135 | 136 | describe('router:register', () => { 137 | it('should register a top-level route', () => { 138 | const ctx = createRouter() 139 | const registry = new Map() 140 | 141 | const Home: Component = { name: 'Home', render: () => null } 142 | const routes: VRouteRaw[] = [{ 143 | path: '/', 144 | component: Home, 145 | meta: { title: 'Home' }, 146 | }] 147 | 148 | registerRoutes(ctx, routes, registry) 149 | 150 | expect(registry.size).toBe(1) 151 | 152 | const [id, route] = getRouteEntry(registry, '/', 0) 153 | expect(Object.isFrozen(id)).toBe(true) 154 | expect(id).toEqual(['/', 0]) 155 | expect(route.component).toBe(Home) 156 | expect(route.meta).toEqual({ title: 'Home' }) 157 | expect(route.parentId).toBeUndefined() 158 | 159 | const match = findRoute(ctx, 'GET', '/') 160 | expect(match).toBeDefined() 161 | expect(match?.data.id).toEqual(id) 162 | }) 163 | 164 | it('should handle nested routes', () => { 165 | const ctx = createRouter() 166 | const registry = new Map() 167 | 168 | const Parent: Component = { name: 'Parent', render: () => null } 169 | const Child: Component = { name: 'Child', render: () => null } 170 | const Grandchild: Component = { name: 'Grandchild', render: () => null } 171 | 172 | const routes: VRouteRaw[] = [ 173 | { 174 | path: '/parent', 175 | component: Parent, 176 | children: [ 177 | { path: '', component: Child, meta: { depth: 1 } }, 178 | { path: 'grand', component: Grandchild }, 179 | ], 180 | }, 181 | ] 182 | 183 | registerRoutes(ctx, routes, registry) 184 | 185 | expect(registry.size).toBe(3) 186 | const [, dataChild] = getRouteEntry(registry, '/parent', 1) 187 | expect(dataChild.meta).toEqual({ depth: 1 }) 188 | expect(dataChild.parentId).toEqual(['/parent', 0]) 189 | 190 | const [, dataGrand] = getRouteEntry(registry, '/parent/grand', 1) 191 | expect(dataGrand.parentId).toEqual(['/parent', 0]) 192 | 193 | expect(findRoute(ctx, 'GET', '/parent')?.data.id).toEqual(['/parent', 1]) 194 | expect(findRoute(ctx, 'GET', '/parent/grand')?.data.id).toEqual(['/parent/grand', 1]) 195 | }) 196 | 197 | it('should let default-child override parent when matching', () => { 198 | const ctx = createRouter() 199 | const registry = new Map() 200 | 201 | const routes: VRouteRaw[] = [ 202 | { 203 | path: '/parent', 204 | component: () => null, 205 | meta: { foo: 'bar' }, 206 | children: [ 207 | { path: '', component: () => null, meta: { foo: 'baz' } }, 208 | ], 209 | }, 210 | ] 211 | 212 | registerRoutes(ctx, routes, registry) 213 | 214 | const match = findRoute(ctx, 'GET', '/parent') 215 | expect(match).toBeDefined() 216 | 217 | expect(match!.data.id).toEqual(['/parent', 1]) 218 | expect(match!.data.meta).toEqual({ foo: 'baz' }) 219 | }) 220 | 221 | it('should avoid registering shadowed parent path in rou3', () => { 222 | const ctx = createRouter() 223 | const registry = new Map() 224 | 225 | const addRouteMock = vi.mocked(rou3.addRoute) 226 | 227 | const routes: VRouteRaw[] = [ 228 | { 229 | path: '/parent', 230 | component: () => null, 231 | meta: { parent: true }, 232 | children: [ 233 | { 234 | path: '', 235 | component: () => null, 236 | meta: { child: true }, 237 | }, 238 | ], 239 | }, 240 | ] 241 | 242 | registerRoutes(ctx, routes, registry) 243 | 244 | expect(addRouteMock).toHaveBeenCalledTimes(1) 245 | expect(addRouteMock).toHaveBeenCalledWith( 246 | ctx, 247 | 'GET', 248 | '/parent', 249 | expect.objectContaining({ id: ['/parent', 1], meta: { child: true } }), 250 | ) 251 | 252 | const match = findRoute(ctx, 'GET', '/parent') 253 | expect(match?.data.id).toEqual(['/parent', 1]) 254 | expect(match?.data.meta).toEqual({ child: true }) 255 | }) 256 | 257 | it('should normalize components', async () => { 258 | const ctx = createRouter() 259 | const registry = new Map() 260 | 261 | const StaticComponent: Component = { name: 'Static', render: () => null } 262 | const AsyncComponent: Component = defineAsyncComponent(async () => ({ name: 'Async', render: () => null })) 263 | const LazyComponent: Component = async () => Promise.resolve({ name: 'Lazy', render: () => null }) 264 | 265 | const routes: VRouteRaw[] = [ 266 | { path: '/static', component: StaticComponent }, 267 | { path: '/async', component: AsyncComponent }, 268 | { path: '/lazy', component: LazyComponent }, 269 | ] 270 | 271 | registerRoutes(ctx, routes, registry) 272 | 273 | const [, dataStatic] = getRouteEntry(registry, '/static', 0) 274 | expect(dataStatic.component).toBe(StaticComponent) 275 | 276 | const [, dataAsync] = getRouteEntry(registry, '/async', 0) 277 | expect(dataAsync.component).toBe(AsyncComponent) 278 | 279 | const [, dataLazy] = getRouteEntry(registry, '/lazy', 0) 280 | expect(dataLazy.component).not.toBe(LazyComponent) 281 | // @ts-expect-error internal loader 282 | expect(typeof dataLazy.component.__asyncLoader).toBe('function') 283 | }) 284 | }) 285 | 286 | describe('router:render', () => { 287 | it('should create the render list in parent-to-child order', () => { 288 | const ctx = createRouter() 289 | const registry = new Map() 290 | 291 | const A: Component = { name: 'A', render: () => null } 292 | const B: Component = { name: 'B', render: () => null } 293 | const C: Component = { name: 'C', render: () => null } 294 | 295 | const routes: VRouteRaw[] = [ 296 | { 297 | path: '/a', 298 | component: A, 299 | children: [ 300 | { 301 | path: 'b', 302 | component: B, 303 | children: [ 304 | { path: 'c', component: C }, 305 | ], 306 | }, 307 | ], 308 | }, 309 | ] 310 | 311 | registerRoutes(ctx, routes, registry) 312 | const match = findRoute(ctx, 'GET', '/a/b/c')! 313 | const list = createRenderList(match.data, registry) 314 | expect(list.map(c => c.name)).toEqual(['A', 'B', 'C']) 315 | }) 316 | 317 | it('should place default-child at the correct position', () => { 318 | const ctx = createRouter() 319 | const registry = new Map() 320 | 321 | const Parent: Component = { name: 'Parent', render: () => null } 322 | const Child: Component = { name: 'Child', render: () => null } 323 | 324 | const routes: VRouteRaw[] = [ 325 | { 326 | path: '/parent', 327 | component: Parent, 328 | children: [ 329 | { path: '', component: Child }, 330 | ], 331 | }, 332 | ] 333 | 334 | registerRoutes(ctx, routes, registry) 335 | const match = findRoute(ctx, 'GET', '/parent')! 336 | const list = createRenderList(match.data, registry) 337 | expect(list.map(c => c.name)).toEqual(['Parent', 'Child']) 338 | }) 339 | 340 | it('should return the same array instance on repeated calls (cache)', () => { 341 | const ctx = createRouter() 342 | const registry = new Map() 343 | 344 | const A: Component = { name: 'A', render: () => null } 345 | const routes: VRouteRaw[] = [{ path: '/a', component: A }] 346 | registerRoutes(ctx, routes, registry) 347 | 348 | const match = findRoute(ctx, 'GET', '/a')! 349 | const first = createRenderList(match.data, registry) 350 | const second = createRenderList(match.data, registry) 351 | expect(second).toBe(first) 352 | }) 353 | 354 | it('should return an empty array for a non-matched route', () => { 355 | const registry = new Map() 356 | const fakeData = { id: ['/', 0] as const, meta: {}, params: undefined } 357 | const list = createRenderList(fakeData as VRouteMatchedData, registry) 358 | expect(list).toEqual([]) 359 | }) 360 | }) 361 | -------------------------------------------------------------------------------- /packages/core/test/components.test.ts: -------------------------------------------------------------------------------- 1 | import type { VRoute, VRouter, VRouteRaw } from '@virou/core' 2 | import type { VNodeChild } from 'vue' 3 | import { useVRouter, virou, VRouterView } from '@virou/core' 4 | import { afterEach, describe, expect, it, vi } from 'vitest' 5 | import { defineAsyncComponent, defineComponent, h, KeepAlive, nextTick, onMounted, ref } from 'vue' 6 | import { mountWithPlugin } from './_utils' 7 | 8 | describe('components:VRouterView', () => { 9 | afterEach(() => { 10 | vi.clearAllTimers() 11 | vi.useRealTimers() 12 | }) 13 | 14 | it('throws if neither a `routerKey` prop nor injected key is provided', () => { 15 | expect(() => mountWithPlugin(VRouterView)) 16 | .toThrowError('[virou] [VRouterView] routerKey is required') 17 | }) 18 | 19 | it('should use the injected key if no `routerKey` prop is provided', () => { 20 | const routes: VRouteRaw[] = [{ path: '/', component: defineComponent({ name: 'Injected', render: () => null }) }] 21 | 22 | const wrapper = mountWithPlugin(defineComponent({ 23 | setup() { 24 | useVRouter('injected', routes) 25 | return () => h(VRouterView) 26 | }, 27 | })) 28 | 29 | expect(wrapper.findComponent({ name: 'Injected' }).exists()).toBe(true) 30 | }) 31 | 32 | it('should prop `routerKey` takes precedence over injected key', () => { 33 | const injectedRoutes: VRouteRaw[] = [{ path: '/', component: defineComponent({ name: 'Injected', render: () => null }) }] 34 | const explicitRoutes: VRouteRaw[] = [{ path: '/', component: defineComponent({ name: 'Explicit', render: () => null }) }] 35 | 36 | const wrapper = mountWithPlugin(defineComponent({ 37 | setup() { 38 | useVRouter('explicit', explicitRoutes) 39 | useVRouter('injected', injectedRoutes) 40 | return () => h('div', [ 41 | h(VRouterView), 42 | h(VRouterView, { routerKey: 'explicit' }), 43 | ]) 44 | }, 45 | })) 46 | 47 | expect(wrapper.findComponent({ name: 'Injected' }).exists()).toBe(true) 48 | expect(wrapper.findComponent({ name: 'Explicit' }).exists()).toBe(true) 49 | }) 50 | 51 | it('should render correct component at depth 0', () => { 52 | const routes: VRouteRaw[] = [{ path: '/', component: defineComponent({ name: 'Root', render: () => null }) }] 53 | 54 | const wrapper = mountWithPlugin(defineComponent({ 55 | setup() { 56 | useVRouter(routes) 57 | return () => h(VRouterView) 58 | }, 59 | })) 60 | 61 | expect(wrapper.findComponent({ name: 'Root' }).exists()).toBe(true) 62 | }) 63 | 64 | it('should renders parent and nested child components', () => { 65 | const routes: VRouteRaw[] = [ 66 | { 67 | path: '/', 68 | component: defineComponent({ name: 'Parent', render: () => h('div', [h(VRouterView)]) }), 69 | children: [ 70 | { 71 | path: 'child', 72 | component: defineComponent({ name: 'Child', render: () => null }), 73 | }, 74 | ], 75 | }, 76 | ] 77 | 78 | const wrapper = mountWithPlugin(defineComponent({ 79 | setup() { 80 | useVRouter(routes, { initialPath: '/child' }) 81 | return () => h(VRouterView) 82 | }, 83 | })) 84 | 85 | expect(wrapper.findComponent({ name: 'Parent' }).exists()).toBe(true) 86 | expect(wrapper.findComponent({ name: 'Child' }).exists()).toBe(true) 87 | }) 88 | 89 | it('should render correct render list on route change', async () => { 90 | const routes: VRouteRaw[] = [ 91 | { 92 | path: '/foo', 93 | component: defineComponent({ name: 'Foo', render: () => null }), 94 | }, 95 | { 96 | path: '/bar', 97 | component: defineComponent({ name: 'Bar', render: () => [h(VRouterView)] }), 98 | children: [ 99 | { 100 | path: '', 101 | component: defineComponent({ name: 'Baz', render: () => null }), 102 | }, 103 | { 104 | path: 'qux', 105 | component: defineComponent({ name: 'Qux', render: () => null }), 106 | }, 107 | ], 108 | }, 109 | ] 110 | 111 | let router!: VRouter['router'] 112 | 113 | const wrapper = mountWithPlugin(defineComponent({ 114 | setup() { 115 | const { router: _router } = useVRouter(routes, { initialPath: '/foo' }) 116 | router = _router 117 | return () => h(VRouterView) 118 | }, 119 | })) 120 | 121 | expect(wrapper.findComponent({ name: 'Foo' }).exists()).toBe(true) 122 | 123 | expect(wrapper.findComponent({ name: 'Bar' }).exists()).toBe(false) 124 | expect(wrapper.findComponent({ name: 'Baz' }).exists()).toBe(false) 125 | 126 | router.replace('/bar') 127 | 128 | await nextTick() 129 | 130 | expect(wrapper.findComponent({ name: 'Foo' }).exists()).toBe(false) 131 | 132 | expect(wrapper.findComponent({ name: 'Bar' }).exists()).toBe(true) 133 | expect(wrapper.findComponent({ name: 'Baz' }).exists()).toBe(true) 134 | 135 | router.replace('/bar/qux') 136 | 137 | await nextTick() 138 | 139 | expect(wrapper.findComponent({ name: 'Foo' }).exists()).toBe(false) 140 | expect(wrapper.findComponent({ name: 'Bar' }).exists()).toBe(true) 141 | expect(wrapper.findComponent({ name: 'Baz' }).exists()).toBe(false) 142 | expect(wrapper.findComponent({ name: 'Qux' }).exists()).toBe(true) 143 | }) 144 | 145 | it('should generate default viewKey when no `viewKey` prop is provided', () => { 146 | const Root = defineComponent({ 147 | name: 'Root', 148 | setup: () => () => h('div', 'root'), 149 | }) 150 | 151 | const routes: VRouteRaw[] = [{ path: '/', component: Root }] 152 | 153 | const wrapper = mountWithPlugin(defineComponent({ 154 | setup() { 155 | useVRouter('abc', routes) 156 | return () => h(VRouterView) 157 | }, 158 | })) 159 | 160 | const rootWrapper = wrapper.findComponent(Root) 161 | expect(rootWrapper.exists()).toBe(true) 162 | expect(rootWrapper.vm.$.vnode.key).toBe('abc-0-/') 163 | }) 164 | 165 | it('should use the string `viewKey` prop as the vnode key', () => { 166 | const Root = defineComponent({ 167 | name: 'Root', 168 | setup: () => () => h('div', 'root'), 169 | }) 170 | const routes: VRouteRaw[] = [{ path: '/', component: Root }] 171 | 172 | const wrapper = mountWithPlugin(defineComponent({ 173 | setup() { 174 | useVRouter('abc', routes) 175 | return () => h(VRouterView, { viewKey: 'my-key' }) 176 | }, 177 | })) 178 | 179 | const rootWrapper = wrapper.findComponent(Root) 180 | expect(rootWrapper.exists()).toBe(true) 181 | expect(rootWrapper.vm.$.vnode.key).toBe('my-key') 182 | }) 183 | 184 | it('should use the function `viewKey` prop to compute the vnode key', () => { 185 | const Root = defineComponent({ 186 | name: 'Root', 187 | setup: () => () => h('div', 'root'), 188 | }) 189 | const routes: VRouteRaw[] = [{ path: '/', component: Root }] 190 | 191 | const wrapper = mountWithPlugin(defineComponent({ 192 | setup() { 193 | useVRouter('abc', routes) 194 | return () => h(VRouterView, { 195 | routerKey: 'abc', 196 | viewKey: (route, key) => `${key}|${route.path}|depth${route['~renderList']!.length}`, 197 | }) 198 | }, 199 | })) 200 | 201 | const rootWrapper = wrapper.findComponent(Root) 202 | expect(rootWrapper.exists()).toBe(true) 203 | expect(rootWrapper.vm.$.vnode.key).toBe('abc|/|depth1') 204 | }) 205 | 206 | it('should warp the component with KeepAlive when `keepAlive` prop is true', () => { 207 | const Root = defineComponent({ 208 | name: 'Root', 209 | setup: () => null, 210 | }) 211 | 212 | const routes: VRouteRaw[] = [{ path: '/', component: Root }] 213 | 214 | const wrapper = mountWithPlugin(defineComponent({ 215 | setup() { 216 | useVRouter(routes) 217 | return () => h(VRouterView, { keepAlive: true }) 218 | }, 219 | })) 220 | 221 | const ka = wrapper.findComponent(KeepAlive) 222 | expect(ka.exists()).toBe(true) 223 | expect(ka.findComponent(Root).exists()).toBe(true) 224 | }) 225 | 226 | it('should preserve component instance state when `keepAlive` is true across route changes', async () => { 227 | const Counter = defineComponent({ 228 | name: 'Counter', 229 | setup() { 230 | const count = ref(0) 231 | onMounted(() => { 232 | count.value++ 233 | }) 234 | return () => h('div', `count: ${count.value}`) 235 | }, 236 | }) 237 | 238 | const routes: VRouteRaw[] = [ 239 | { path: '/', component: Counter }, 240 | { path: '/other', component: { name: 'Other', render: () => null } }, 241 | ] 242 | 243 | let router!: VRouter['router'] 244 | 245 | const wrapper = mountWithPlugin(defineComponent({ 246 | setup() { 247 | const { router: r } = useVRouter(routes) 248 | router = r 249 | return () => h(VRouterView, { keepAlive: true }) 250 | }, 251 | })) 252 | 253 | await nextTick() 254 | expect(wrapper.text()).toBe('count: 1') 255 | 256 | router.replace('/other') 257 | await nextTick() 258 | expect(wrapper.text()).toBe('') 259 | 260 | router.replace('/') 261 | await nextTick() 262 | expect(wrapper.text()).toBe('count: 1') 263 | }) 264 | 265 | it('should preserve each child component instance state when `keepAlive` is true across route changes', async () => { 266 | const createCounter = (name: string, nested = false) => defineComponent({ 267 | name, 268 | setup() { 269 | const count = ref(0) 270 | onMounted(() => { 271 | count.value++ 272 | }) 273 | return () => h('div', [ 274 | h('span', { 'data-test': name }, `${name} count: ${count.value}`), 275 | ...nested ? [h(VRouterView, { keepAlive: true })] : [], 276 | ]) 277 | }, 278 | }) 279 | 280 | const [Wrapper, DefaultChild, Child, Other] = [ 281 | createCounter('Wrapper', true), 282 | createCounter('DefaultChild'), 283 | createCounter('Child'), 284 | createCounter('Other'), 285 | ] 286 | 287 | const routes: VRouteRaw[] = [ 288 | { 289 | path: '/', 290 | component: Wrapper, 291 | children: [ 292 | { 293 | path: '', 294 | component: DefaultChild, 295 | }, 296 | { 297 | path: '/child', 298 | component: Child, 299 | }, 300 | ], 301 | }, 302 | { 303 | path: '/other', 304 | component: Other, 305 | }, 306 | ] 307 | 308 | let router!: VRouter['router'] 309 | const wrapper = mountWithPlugin(defineComponent({ 310 | setup() { 311 | const { router: r } = useVRouter(routes) 312 | router = r 313 | return () => h(VRouterView, { keepAlive: true }) 314 | }, 315 | })) 316 | 317 | await nextTick() 318 | 319 | expect(wrapper.find('[data-test="Wrapper"]').text()).toContain('Wrapper count: 1') 320 | expect(wrapper.find('[data-test="DefaultChild"]').text()).toContain('DefaultChild count: 1') 321 | 322 | router.replace('/child') 323 | await nextTick() 324 | expect(wrapper.find('[data-test="Wrapper"]').text()).toContain('Wrapper count: 1') 325 | expect(wrapper.find('[data-test="Child"]').text()).toContain('Child count: 1') 326 | 327 | router.replace('/other') 328 | await nextTick() 329 | expect(wrapper.find('[data-test="Other"]').text()).toContain('Other count: 1') 330 | 331 | router.replace('/') 332 | await nextTick() 333 | expect(wrapper.find('[data-test="Wrapper"]').text()).toContain('Wrapper count: 1') 334 | expect(wrapper.find('[data-test="DefaultChild"]').text()).toContain('DefaultChild count: 1') 335 | 336 | router.replace('/child') 337 | await nextTick() 338 | expect(wrapper.find('[data-test="Wrapper"]').text()).toContain('Wrapper count: 1') 339 | expect(wrapper.find('[data-test="Child"]').text()).toContain('Child count: 1') 340 | }) 341 | 342 | it('should render the fallback slot while an async component is loading', async () => { 343 | vi.useFakeTimers() 344 | 345 | const timeOut = 1000 346 | 347 | const Comp = defineComponent({ 348 | name: 'AsyncComp', 349 | setup() { 350 | return () => h('div', 'I loaded after 1 second!') 351 | }, 352 | }) 353 | 354 | const AsyncComp = defineAsyncComponent(async () => 355 | new Promise((resolve) => { 356 | setTimeout(() => { 357 | resolve(Comp) 358 | }, timeOut) 359 | }), 360 | ) 361 | 362 | const routes: VRouteRaw[] = [{ path: '/', component: AsyncComp }] 363 | 364 | const wrapper = mountWithPlugin(defineComponent({ 365 | setup() { 366 | useVRouter(routes) 367 | return () => h(VRouterView, null, { fallback: () => h('div', 'Loading...') }) 368 | }, 369 | })) 370 | 371 | expect(wrapper.text()).toBe('Loading...') 372 | 373 | await vi.advanceTimersByTimeAsync(timeOut) 374 | await nextTick() 375 | 376 | expect(wrapper.text()).toBe('I loaded after 1 second!') 377 | }) 378 | 379 | it('should expose the routed component and route object via the default slot', async () => { 380 | const Root = defineComponent({ 381 | name: 'Root', 382 | render: () => h('div', 'root content'), 383 | }) 384 | 385 | const routes: VRouteRaw[] = [{ path: '/', component: Root }] 386 | 387 | const wrapper = mountWithPlugin(defineComponent({ 388 | setup() { 389 | useVRouter(routes) 390 | return () => 391 | h(VRouterView, null, { 392 | default: ({ Component, route }: { Component: VNodeChild, route: VRoute }) => 393 | h('div', [ 394 | Component, 395 | h('span', `path: ${route.path}`), 396 | ]), 397 | }) 398 | }, 399 | })) 400 | 401 | expect(wrapper.findComponent(Root).exists()).toBe(true) 402 | expect(wrapper.text()).toContain('root content') 403 | expect(wrapper.text()).toContain('path: /') 404 | }) 405 | 406 | it('should forward arbitrary attrs and props to the rendered component', () => { 407 | const Root = defineComponent({ 408 | name: 'Root', 409 | props: { foo: String }, 410 | setup(props) { 411 | return () => h('div', { 'data-foo': props.foo }, `foo is ${props.foo}`) 412 | }, 413 | }) 414 | 415 | const routes: VRouteRaw[] = [{ path: '/', component: Root }] 416 | 417 | const wrapper = mountWithPlugin( 418 | defineComponent({ 419 | setup() { 420 | useVRouter(routes) 421 | return () => 422 | h(VRouterView, { 423 | 'foo': 'hello-world', 424 | 'data-test': 'wrapped', 425 | }) 426 | }, 427 | }), 428 | { global: { plugins: [virou] } }, 429 | ) 430 | 431 | const root = wrapper.findComponent(Root) 432 | expect(root.exists()).toBe(true) 433 | expect(root.props('foo')).toBe('hello-world') 434 | expect(root.attributes('data-test')).toBe('wrapped') 435 | expect(root.text()).toBe('foo is hello-world') 436 | }) 437 | 438 | it('should render the default slot when there is no matched component', () => { 439 | const routes: VRouteRaw[] = [ 440 | { 441 | path: '/', 442 | component: defineComponent({ 443 | name: 'Root', 444 | render: () => h('div', 'root'), 445 | }), 446 | }, 447 | ] 448 | 449 | const wrapper = mountWithPlugin( 450 | defineComponent({ 451 | setup() { 452 | useVRouter(routes, { initialPath: '/not-exist' }) 453 | return () => 454 | h(VRouterView, undefined, { 455 | default: ({ Component, route }: { Component: VNodeChild, route: VRoute }) => Component ?? h('span', `no match for ${route.fullPath}`), 456 | }) 457 | }, 458 | }), 459 | { global: { plugins: [virou] } }, 460 | ) 461 | 462 | expect(wrapper.find('span').text()).toBe('no match for /not-exist') 463 | }) 464 | }) 465 | --------------------------------------------------------------------------------