├── .babelrc ├── .eslintrc ├── .github ├── FUNDING.yml └── workflows │ └── build-and-test.yml ├── .gitignore ├── .husky └── pre-commit ├── .prettierignore ├── LICENSE ├── README.md ├── jest.config.mjs ├── package.json ├── pnpm-lock.yaml ├── rollup.config.mjs ├── src ├── parcel.js ├── parcel.test.js ├── single-spa-vue.js └── single-spa-vue.test.js └── types ├── single-spa-vue.d.ts └── single-spa-vue.test-d.ts /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "bugfixes": true 7 | } 8 | ] 9 | ], 10 | "env": { 11 | "test": { 12 | "presets": [ 13 | [ 14 | "@babel/preset-env", 15 | { 16 | "targets": "current node" 17 | } 18 | ] 19 | ] 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["eslint-config-important-stuff"], 3 | "parser": "@babel/eslint-parser" 4 | } 5 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: ["jolyndenning"] 4 | patreon: singlespa 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.github/workflows/build-and-test.yml: -------------------------------------------------------------------------------- 1 | on: 2 | - push 3 | - pull_request 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@v4 11 | - uses: actions/setup-node@v4 12 | with: 13 | node-version: "22" 14 | - uses: pnpm/action-setup@v4 15 | - run: pnpm install --frozen-lockfile 16 | - run: pnpm run build 17 | - run: pnpm test 18 | - run: pnpm run check-format 19 | - run: pnpm run lint 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Optional npm cache directory 34 | .npm 35 | 36 | # Optional REPL history 37 | .node_repl_history 38 | 39 | dist -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | pnpm exec pretty-quick --staged && pnpm exec concurrently -n pnpm:test pnpm:lint -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .prettierignore 2 | .gitignore 3 | dist 4 | LICENSE 5 | yarn.lock 6 | pnpm-lock.yaml -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Bret Little 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # single-spa-vue 2 | 3 | Generic lifecycle hooks for Vue.js applications that are registered as [applications](https://single-spa.js.org/docs/building-applications) of [single-spa](https://github.com/single-spa/single-spa). 4 | 5 | [Full documentation](https://single-spa.js.org/docs/ecosystem-vue.html) 6 | -------------------------------------------------------------------------------- /jest.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('@jest/types').Config.ProjectConfig} */ 2 | const config = { 3 | testEnvironment: "jsdom", 4 | transform: { 5 | "\\.[cm]?[jt]sx?$": "babel-jest", 6 | }, 7 | }; 8 | 9 | export default config; 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "single-spa-vue", 3 | "version": "3.0.1", 4 | "description": "a single-spa plugin for vue.js applications", 5 | "main": "dist/umd/single-spa-vue.js", 6 | "module": "dist/esm/single-spa-vue.js", 7 | "packageManager": "pnpm@9.15.4", 8 | "exports": { 9 | ".": { 10 | "types": "./types/single-spa-vue.d.ts", 11 | "import": "./dist/esm/single-spa-vue.js", 12 | "require": "./dist/umd/single-spa-vue.js" 13 | }, 14 | "./parcel": { 15 | "import": "./dist/esm/parcel.js", 16 | "require": "./dist/umd/parcel.js" 17 | } 18 | }, 19 | "scripts": { 20 | "prepublishOnly": "pnpm run build", 21 | "build": "rimraf dist && rollup -c", 22 | "build:watch": "rollup -cw", 23 | "test": "concurrently -n w: 'pnpm:test:*'", 24 | "test:jest": "cross-env BABEL_ENV=test jest", 25 | "test:types": "tsd", 26 | "format": "prettier --write .", 27 | "check-format": "prettier --check .", 28 | "lint": "eslint src", 29 | "prepare": "husky install" 30 | }, 31 | "repository": { 32 | "type": "git", 33 | "url": "git+https://github.com/single-spa/single-spa-vue.git" 34 | }, 35 | "files": [ 36 | "dist", 37 | "types" 38 | ], 39 | "types": "types/single-spa-vue.d.ts", 40 | "keywords": [ 41 | "single-spa", 42 | "vue", 43 | "single", 44 | "page", 45 | "app", 46 | "spa" 47 | ], 48 | "author": "single-spa core team", 49 | "license": "MIT", 50 | "bugs": { 51 | "url": "https://github.com/single-spa/single-spa-vue/issues" 52 | }, 53 | "homepage": "https://github.com/single-spa/single-spa-vue#readme", 54 | "devDependencies": { 55 | "@babel/core": "^7.22.5", 56 | "@babel/eslint-parser": "^7.22.5", 57 | "@babel/preset-env": "^7.22.5", 58 | "@rollup/plugin-babel": "^6.0.3", 59 | "@rollup/plugin-commonjs": "^25.0.2", 60 | "@rollup/plugin-node-resolve": "^15.1.0", 61 | "@rollup/plugin-terser": "^0.4.3", 62 | "@types/jest": "^29.5.2", 63 | "@vue/test-utils": "^1.1.1", 64 | "babel-jest": "^29.5.0", 65 | "concurrently": "^8.2.0", 66 | "cross-env": "^7.0.2", 67 | "css.escape": "^1.5.1", 68 | "eslint": "^8.43.0", 69 | "eslint-config-important-stuff": "^1.1.0", 70 | "husky": "^8.0.0", 71 | "jest": "^29.5.0", 72 | "jest-environment-jsdom": "^29.5.0", 73 | "prettier": "^2.8.8", 74 | "pretty-quick": "^3.1.3", 75 | "rimraf": "^5.0.1", 76 | "rollup": "^3.25.3", 77 | "single-spa": "^5.9.5", 78 | "tsd": "^0.28.1", 79 | "vue": "^2.6.12", 80 | "vue-template-compiler": "^2.6.12" 81 | }, 82 | "tsd": { 83 | "compilerOptions": { 84 | "lib": [ 85 | "dom" 86 | ] 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /rollup.config.mjs: -------------------------------------------------------------------------------- 1 | import { babel } from "@rollup/plugin-babel"; 2 | import resolve from "@rollup/plugin-node-resolve"; 3 | import commonjs from "@rollup/plugin-commonjs"; 4 | import terser from "@rollup/plugin-terser"; 5 | 6 | export default [ 7 | ...createConfig("umd"), 8 | ...createConfig("esm"), 9 | ...createConfig("system"), 10 | ]; 11 | 12 | function createConfig(format) { 13 | return [ 14 | { 15 | input: "./src/single-spa-vue.js", 16 | output: { 17 | dir: `dist/${format}`, 18 | name: format === "umd" ? "singleSpaVue" : null, 19 | sourcemap: true, 20 | format: format, 21 | }, 22 | plugins: [ 23 | babel({ 24 | exclude: "node_modules/**", 25 | babelHelpers: "inline", 26 | }), 27 | resolve(), 28 | commonjs(), 29 | terser(), 30 | ], 31 | }, 32 | { 33 | input: "./src/parcel.js", 34 | output: { 35 | dir: `dist/${format}`, 36 | name: format === "umd" ? "singleSpaVueParcel" : null, 37 | sourcemap: true, 38 | format, 39 | }, 40 | plugins: [ 41 | babel({ 42 | exclude: "node_modules/**", 43 | babelHelpers: "inline", 44 | }), 45 | resolve(), 46 | commonjs(), 47 | terser(), 48 | ], 49 | external: ["vue"], 50 | }, 51 | ]; 52 | } 53 | -------------------------------------------------------------------------------- /src/parcel.js: -------------------------------------------------------------------------------- 1 | import * as Vue from "vue"; 2 | 3 | const lessThanVue3 = !Vue.version || /^[012]\..+/.test(Vue.version); 4 | 5 | export default { 6 | props: { 7 | config: [Object, Promise], 8 | wrapWith: String, 9 | wrapClass: String, 10 | wrapStyle: Object, 11 | mountParcel: Function, 12 | parcelProps: Object, 13 | }, 14 | render(h) { 15 | // Vue 2 works differently than Vue 3 16 | h = typeof h === "function" ? h : Vue.h || (Vue.default && Vue.default.h); 17 | 18 | const containerTagName = this.wrapWith || "div"; 19 | const props = { ref: "container" }; 20 | if (this.wrapClass) { 21 | props.class = this.wrapClass; 22 | } 23 | if (this.wrapStyle) { 24 | props.style = this.wrapStyle; 25 | } 26 | return h(containerTagName, props); 27 | }, 28 | data() { 29 | return { 30 | hasError: false, 31 | }; 32 | }, 33 | methods: { 34 | addThingToDo(action, thing) { 35 | if (this.hasError && action !== "unmount") { 36 | return; 37 | } 38 | 39 | this.nextThingToDo = (this.nextThingToDo || Promise.resolve()) 40 | .then((...args) => { 41 | if (this.unmounted && action !== "unmount") { 42 | return; 43 | } 44 | 45 | return thing.apply(this, args); 46 | }) 47 | .catch((err) => { 48 | this.nextThingToDo = Promise.resolve(); 49 | this.hasError = true; 50 | 51 | if (err && err.message) { 52 | err.message = `During '${action}', parcel threw an error: ${err.message}`; 53 | } 54 | 55 | this.$emit("parcelError", err); 56 | 57 | throw err; 58 | }); 59 | }, 60 | singleSpaMount() { 61 | this.parcel = this.mountParcel(this.config, this.getParcelProps()); 62 | 63 | return this.parcel.mountPromise.then(() => { 64 | this.$emit("parcelMounted"); 65 | }); 66 | }, 67 | singleSpaUnmount() { 68 | if (this.parcel && this.parcel.getStatus() === "MOUNTED") { 69 | return this.parcel.unmount(); 70 | } 71 | }, 72 | singleSpaUpdate() { 73 | if (this.parcel && this.parcel.update) { 74 | return this.parcel.update(this.getParcelProps()).then(() => { 75 | this.$emit("parcelUpdated"); 76 | }); 77 | } 78 | }, 79 | getParcelProps() { 80 | return { 81 | domElement: this.$refs.container, 82 | ...(this.parcelProps || {}), 83 | }; 84 | }, 85 | }, 86 | mounted() { 87 | if (!this.config) { 88 | throw Error(`single-spa-vue: component requires a config prop.`); 89 | } 90 | 91 | if (!this.mountParcel) { 92 | throw Error( 93 | `single-spa-vue: component requires a mountParcel prop` 94 | ); 95 | } 96 | 97 | if (this.config) { 98 | this.addThingToDo("mount", this.singleSpaMount); 99 | } 100 | }, 101 | [lessThanVue3 ? "destroyed" : "unmounted"]() { 102 | this.addThingToDo("unmount", this.singleSpaUnmount); 103 | }, 104 | watch: { 105 | parcelProps: { 106 | handler(parcelProps) { 107 | this.addThingToDo("update", this.singleSpaUpdate); 108 | }, 109 | }, 110 | }, 111 | }; 112 | -------------------------------------------------------------------------------- /src/parcel.test.js: -------------------------------------------------------------------------------- 1 | import { mount } from "@vue/test-utils"; 2 | import { 3 | mountRootParcel, 4 | registerApplication, 5 | start, 6 | triggerAppChange, 7 | unregisterApplication, 8 | } from "single-spa"; 9 | import Parcel from "./parcel.js"; 10 | 11 | describe("Parcel", () => { 12 | let wrapper; 13 | 14 | beforeAll(() => { 15 | start(); 16 | }); 17 | 18 | afterEach(() => { 19 | if (wrapper) { 20 | wrapper.destroy(); 21 | } 22 | 23 | wrapper = null; 24 | }); 25 | 26 | it("should throw an error if incorrect props are provided", async () => { 27 | try { 28 | mount(Parcel); 29 | fail("Mounting should fail without config prop"); 30 | } catch (err) { 31 | expect(err.message).toMatch(/component requires a config prop/); 32 | } 33 | }); 34 | 35 | it("should render if config and mountParcel are provided", async () => { 36 | const wrapper = await mount(Parcel, { 37 | propsData: { 38 | config: createParcelConfig(), 39 | mountParcel: mountRootParcel, 40 | }, 41 | }); 42 | 43 | await tick(); 44 | 45 | expect(wrapper.emitted().parcelMounted).toBeTruthy(); 46 | 47 | expect(wrapper.find("button#parcel").exists()).toBe(true); 48 | expect(wrapper.find("button#parcel").text()).toEqual("The parcel button"); 49 | }); 50 | 51 | it("should wrap with to div if no 'wrapWith' is provided", async () => { 52 | const wrapper = await mount(Parcel, { 53 | propsData: { 54 | config: createParcelConfig(), 55 | mountParcel: mountRootParcel, 56 | }, 57 | }); 58 | 59 | await tick(); 60 | 61 | expect(wrapper.emitted().parcelMounted).toBeTruthy(); 62 | 63 | expect(wrapper.find("div").exists()).toBe(true); 64 | }); 65 | 66 | it("should respect the wrapWith, wrapClass, and wrapStyle props", async () => { 67 | wrapper = await mount(Parcel, { 68 | propsData: { 69 | config: createParcelConfig(), 70 | wrapWith: "span", 71 | wrapClass: "the-class", 72 | wrapStyle: { 73 | backgroundColor: "red", 74 | }, 75 | mountParcel: mountRootParcel, 76 | }, 77 | }); 78 | 79 | await tick(); 80 | 81 | expect(wrapper.emitted().parcelMounted).toBeTruthy(); 82 | 83 | expect(wrapper.find("span").exists()).toBe(true); 84 | expect(wrapper.find("span").classes()).toContain("the-class"); 85 | expect(wrapper.find("span").attributes("style")).toEqual( 86 | "background-color: red;" 87 | ); 88 | 89 | expect(wrapper.find("span").find("button#parcel").exists()).toBe(true); 90 | expect(wrapper.find("span").find("button#parcel").text()).toBe( 91 | "The parcel button" 92 | ); 93 | }); 94 | 95 | it("should unmount properly", async () => { 96 | const config = createParcelConfig(); 97 | const wrapper = await mount(Parcel, { 98 | propsData: { 99 | config, 100 | mountParcel: mountRootParcel, 101 | }, 102 | }); 103 | 104 | await tick(); 105 | 106 | expect(wrapper.emitted().parcelMounted).toBeTruthy(); 107 | expect(config.mounted).toBe(true); 108 | 109 | expect(wrapper.find("button#parcel").exists()).toBe(true); 110 | 111 | await wrapper.destroy(); 112 | 113 | await tick(); 114 | 115 | expect(config.mounted).toBe(false); 116 | }); 117 | 118 | it("forwards parcelProps to the parcel", async () => { 119 | const config = createParcelConfig(); 120 | const wrapper = await mount(Parcel, { 121 | propsData: { 122 | config, 123 | mountParcel: mountRootParcel, 124 | parcelProps: { 125 | foo: "bar", 126 | }, 127 | }, 128 | }); 129 | 130 | await tick(); 131 | 132 | expect(wrapper.emitted().parcelMounted).toBeTruthy(); 133 | expect(config.mounted).toBe(true); 134 | expect(config.props).toMatchObject({ foo: "bar" }); 135 | }); 136 | 137 | it("calls parcel.update when update is defined", async () => { 138 | const config = createParcelConfig({ update: true }); 139 | 140 | const wrapper = await mount(Parcel, { 141 | propsData: { 142 | config, 143 | mountParcel: mountRootParcel, 144 | parcelProps: { 145 | numUsers: 10, 146 | }, 147 | }, 148 | }); 149 | 150 | await tick(); 151 | 152 | expect(wrapper.emitted().parcelMounted).toBeTruthy(); 153 | expect(config.mounted).toBe(true); 154 | expect(config.props).toMatchObject({ 155 | numUsers: 10, 156 | }); 157 | 158 | wrapper.setProps({ 159 | parcelProps: { 160 | numUsers: 100, 161 | }, 162 | }); 163 | 164 | await tick(); 165 | 166 | expect(wrapper.emitted().parcelUpdated).toBeTruthy(); 167 | expect(config.props).toMatchObject({ 168 | numUsers: 100, 169 | }); 170 | }); 171 | 172 | it(`doesn't die when the parcel config doesn't have an update function`, async () => { 173 | const config = createParcelConfig(); 174 | 175 | const wrapper = await mount(Parcel, { 176 | propsData: { 177 | config, 178 | mountParcel: mountRootParcel, 179 | parcelProps: { 180 | numUsers: 10, 181 | }, 182 | }, 183 | }); 184 | 185 | await tick(); 186 | 187 | expect(wrapper.emitted().parcelMounted).toBeTruthy(); 188 | expect(config.mounted).toBe(true); 189 | expect(config.props).toMatchObject({ 190 | numUsers: 10, 191 | }); 192 | 193 | wrapper.setProps({ 194 | parcelProps: { 195 | numUsers: 100, 196 | }, 197 | }); 198 | 199 | await tick(); 200 | 201 | expect(wrapper.emitted().parcelUpdated).toBeFalsy(); 202 | expect(config.props).toMatchObject({ 203 | // since the parcel config doesn't have an update function, 204 | // the numUsers prop on the parcel won't update when the 205 | // vue component updates 206 | numUsers: 10, 207 | }); 208 | }); 209 | 210 | it(`allows you to pass in a promise that resolves with the config object`, async () => { 211 | const config = createParcelConfig(); 212 | 213 | const wrapper = await mount(Parcel, { 214 | propsData: { 215 | config: Promise.resolve(config), 216 | mountParcel: mountRootParcel, 217 | }, 218 | }); 219 | 220 | await tick(); 221 | 222 | expect(wrapper.emitted().parcelMounted).toBeTruthy(); 223 | expect(config.mounted).toBe(true); 224 | expect(wrapper.find("button#parcel").exists()).toBe(true); 225 | }); 226 | 227 | it(`doesn't throw error if parent application is unmounted`, async () => { 228 | let appMounted = true; 229 | const config = createParcelConfig(); 230 | 231 | registerApplication({ 232 | name: "parent-app-unmount", 233 | activeWhen() { 234 | return appMounted; 235 | }, 236 | app: { 237 | async bootstrap() {}, 238 | async mount(props) { 239 | wrapper = await mount(Parcel, { 240 | propsData: { 241 | config, 242 | mountParcel: props.mountParcel, 243 | }, 244 | }); 245 | }, 246 | async unmount() {}, 247 | }, 248 | }); 249 | 250 | await triggerAppChange(); 251 | await tick(); 252 | 253 | expect(config.mounted).toBe(true); 254 | 255 | appMounted = false; 256 | 257 | await triggerAppChange(); 258 | 259 | expect(config.mounted).toBe(false); 260 | 261 | // This is what caused the error in https://github.com/single-spa/single-spa-vue/pull/95 262 | // Trying to unmount the vue component after the single-spa app already unmounted the parcel 263 | await wrapper.destroy(); 264 | 265 | unregisterApplication("parent-app-unmount"); 266 | }); 267 | }); 268 | 269 | function createParcelConfig(opts = {}) { 270 | const result = { 271 | async mount(props) { 272 | const button = document.createElement("button"); 273 | button.textContent = `The parcel button`; 274 | button.id = "parcel"; 275 | props.domElement.appendChild(button); 276 | result.mounted = true; 277 | result.props = props; 278 | }, 279 | async unmount(props) { 280 | props.domElement.querySelector("button").remove(); 281 | result.mounted = false; 282 | result.props = props; 283 | }, 284 | mounted: false, 285 | props: null, 286 | numUpdates: 0, 287 | }; 288 | 289 | if (opts.update) { 290 | result.update = async (props) => { 291 | result.props = props; 292 | result.numUpdates++; 293 | }; 294 | } 295 | 296 | return result; 297 | } 298 | 299 | function tick() { 300 | return new Promise((resolve) => { 301 | setTimeout(resolve); 302 | }); 303 | } 304 | -------------------------------------------------------------------------------- /src/single-spa-vue.js: -------------------------------------------------------------------------------- 1 | import "css.escape"; 2 | 3 | const defaultOpts = { 4 | // required opts 5 | appOptions: null, 6 | template: null, 7 | 8 | // sometimes require opts 9 | Vue: null, 10 | createApp: null, 11 | handleInstance: null, 12 | }; 13 | 14 | export default function singleSpaVue(userOpts) { 15 | if (typeof userOpts !== "object") { 16 | throw new Error(`single-spa-vue requires a configuration object`); 17 | } 18 | 19 | const opts = { 20 | ...defaultOpts, 21 | ...userOpts, 22 | }; 23 | 24 | if (!opts.Vue && !opts.createApp) { 25 | throw Error("single-spa-vue must be passed opts.Vue or opts.createApp"); 26 | } 27 | 28 | if (!opts.appOptions) { 29 | throw Error("single-spa-vue must be passed opts.appOptions"); 30 | } 31 | 32 | if ( 33 | opts.appOptions.el && 34 | typeof opts.appOptions.el !== "string" && 35 | !(opts.appOptions.el instanceof HTMLElement) 36 | ) { 37 | throw Error( 38 | `single-spa-vue: appOptions.el must be a string CSS selector, an HTMLElement, or not provided at all. Was given ${typeof opts 39 | .appOptions.el}` 40 | ); 41 | } 42 | 43 | opts.createApp = opts.createApp || (opts.Vue && opts.Vue.createApp); 44 | 45 | // Just a shared object to store the mounted object state 46 | // key - name of single-spa app, since it is unique 47 | let mountedInstances = {}; 48 | 49 | return { 50 | bootstrap: bootstrap.bind(null, opts, mountedInstances), 51 | mount: mount.bind(null, opts, mountedInstances), 52 | unmount: unmount.bind(null, opts, mountedInstances), 53 | update: update.bind(null, opts, mountedInstances), 54 | }; 55 | } 56 | 57 | function bootstrap(opts) { 58 | if (opts.loadRootComponent) { 59 | return opts.loadRootComponent().then((root) => (opts.rootComponent = root)); 60 | } else { 61 | return Promise.resolve(); 62 | } 63 | } 64 | 65 | function resolveAppOptions(opts, props) { 66 | if (typeof opts.appOptions === "function") { 67 | return opts.appOptions(props); 68 | } 69 | return Promise.resolve({ ...opts.appOptions }); 70 | } 71 | 72 | function mount(opts, mountedInstances, props) { 73 | const instance = {}; 74 | return Promise.resolve().then(() => { 75 | return resolveAppOptions(opts, props).then((appOptions) => { 76 | if (props.domElement && !appOptions.el) { 77 | appOptions.el = props.domElement; 78 | } 79 | 80 | let domEl; 81 | if (appOptions.el) { 82 | if (typeof appOptions.el === "string") { 83 | domEl = document.querySelector(appOptions.el); 84 | if (!domEl) { 85 | throw Error( 86 | `If appOptions.el is provided to single-spa-vue, the dom element must exist in the dom. Was provided as ${appOptions.el}` 87 | ); 88 | } 89 | } else { 90 | domEl = appOptions.el; 91 | if (!domEl.id) { 92 | domEl.id = `single-spa-application:${props.name}`; 93 | } 94 | appOptions.el = `#${CSS.escape(domEl.id)}`; 95 | } 96 | } else { 97 | const htmlId = `single-spa-application:${props.name}`; 98 | appOptions.el = `#${CSS.escape(htmlId)}`; 99 | domEl = document.getElementById(htmlId); 100 | if (!domEl) { 101 | domEl = document.createElement("div"); 102 | domEl.id = htmlId; 103 | document.body.appendChild(domEl); 104 | } 105 | } 106 | 107 | if (!opts.replaceMode) { 108 | appOptions.el = appOptions.el + " .single-spa-container"; 109 | 110 | // single-spa-vue@>=2 always REPLACES the `el` instead of appending to it. 111 | // We want domEl to stick around and not be replaced. So we tell Vue to mount 112 | // into a container div inside of the main domEl 113 | if (!domEl.querySelector(".single-spa-container")) { 114 | const singleSpaContainer = document.createElement("div"); 115 | singleSpaContainer.className = "single-spa-container"; 116 | domEl.appendChild(singleSpaContainer); 117 | } 118 | } 119 | 120 | instance.domEl = domEl; 121 | 122 | if (!appOptions.render && !appOptions.template && opts.rootComponent) { 123 | appOptions.render = (h) => h(opts.rootComponent); 124 | } 125 | 126 | if (!appOptions.data) { 127 | appOptions.data = {}; 128 | } 129 | 130 | const originData = appOptions.data; 131 | appOptions.data = function () { 132 | const data = 133 | typeof originData === "function" 134 | ? originData.call(this, this) 135 | : originData; 136 | return { ...data, ...props }; 137 | }; 138 | 139 | if (opts.createApp) { 140 | instance.vueInstance = opts.createApp(appOptions); 141 | if (opts.handleInstance) { 142 | return Promise.resolve( 143 | opts.handleInstance(instance.vueInstance, props) 144 | ).then(function () { 145 | instance.root = instance.vueInstance.mount(appOptions.el); 146 | mountedInstances[props.name] = instance; 147 | 148 | return instance.vueInstance; 149 | }); 150 | } else { 151 | instance.root = instance.vueInstance.mount(appOptions.el); 152 | } 153 | } else { 154 | instance.vueInstance = new opts.Vue(appOptions); 155 | if (instance.vueInstance.bind) { 156 | instance.vueInstance = instance.vueInstance.bind( 157 | instance.vueInstance 158 | ); 159 | } 160 | if (opts.handleInstance) { 161 | return Promise.resolve( 162 | opts.handleInstance(instance.vueInstance, props) 163 | ).then(function () { 164 | mountedInstances[props.name] = instance; 165 | return instance.vueInstance; 166 | }); 167 | } 168 | } 169 | 170 | mountedInstances[props.name] = instance; 171 | 172 | return instance.vueInstance; 173 | }); 174 | }); 175 | } 176 | 177 | function update(opts, mountedInstances, props) { 178 | return Promise.resolve().then(() => { 179 | const instance = mountedInstances[props.name]; 180 | const data = { 181 | ...(opts.appOptions.data || {}), 182 | ...props, 183 | }; 184 | const root = instance.root || instance.vueInstance; 185 | for (let prop in data) { 186 | root[prop] = data[prop]; 187 | } 188 | }); 189 | } 190 | 191 | function unmount(opts, mountedInstances, props) { 192 | return Promise.resolve().then(() => { 193 | const instance = mountedInstances[props.name]; 194 | if (opts.createApp) { 195 | instance.vueInstance.unmount(instance.domEl); 196 | } else { 197 | instance.vueInstance.$destroy(); 198 | instance.vueInstance.$el.innerHTML = ""; 199 | } 200 | delete instance.vueInstance; 201 | 202 | if (instance.domEl) { 203 | instance.domEl.innerHTML = ""; 204 | delete instance.domEl; 205 | } 206 | }); 207 | } 208 | -------------------------------------------------------------------------------- /src/single-spa-vue.test.js: -------------------------------------------------------------------------------- 1 | import singleSpaVue from "./single-spa-vue"; 2 | 3 | const domElId = `single-spa-application:test-app`; 4 | const cssSelector = `#single-spa-application\\:test-app`; 5 | 6 | describe("single-spa-vue", () => { 7 | let Vue, props, $destroy; 8 | 9 | beforeEach(() => { 10 | Vue = jest.fn(); 11 | 12 | Vue.mockImplementation(function () { 13 | this.$destroy = $destroy; 14 | this.$el = { innerHTML: "" }; 15 | }); 16 | 17 | props = { name: "test-app" }; 18 | 19 | $destroy = jest.fn(); 20 | }); 21 | 22 | afterEach(() => { 23 | document.querySelectorAll(cssSelector).forEach((node) => { 24 | node.remove(); 25 | }); 26 | }); 27 | 28 | it(`calls new Vue() during mount and mountedInstances.instance.$destroy() on unmount`, () => { 29 | const handleInstance = jest.fn(); 30 | 31 | const lifecycles = new singleSpaVue({ 32 | Vue, 33 | appOptions: {}, 34 | handleInstance, 35 | }); 36 | 37 | return lifecycles 38 | .bootstrap(props) 39 | .then(() => { 40 | expect(Vue).not.toHaveBeenCalled(); 41 | expect(handleInstance).not.toHaveBeenCalled(); 42 | expect($destroy).not.toHaveBeenCalled(); 43 | return lifecycles.mount(props); 44 | }) 45 | .then(() => { 46 | expect(Vue).toHaveBeenCalled(); 47 | expect(handleInstance).toHaveBeenCalled(); 48 | expect($destroy).not.toHaveBeenCalled(); 49 | return lifecycles.unmount(props); 50 | }) 51 | .then(() => { 52 | expect($destroy).toHaveBeenCalled(); 53 | }); 54 | }); 55 | 56 | it(`creates a dom element container for you if you don't provide one`, () => { 57 | const lifecycles = new singleSpaVue({ 58 | Vue, 59 | appOptions: {}, 60 | }); 61 | 62 | expect(document.getElementById(domElId)).toBe(null); 63 | 64 | return lifecycles 65 | .bootstrap(props) 66 | .then(() => lifecycles.mount(props)) 67 | .then(() => { 68 | expect(document.getElementById(domElId)).toBeTruthy(); 69 | }); 70 | }); 71 | 72 | it(`uses the appOptions.el selector string if provided, and wraps the single-spa application in a container div`, () => { 73 | document.body.appendChild( 74 | Object.assign(document.createElement("div"), { 75 | id: "my-custom-el", 76 | }) 77 | ); 78 | 79 | const lifecycles = new singleSpaVue({ 80 | Vue, 81 | appOptions: { 82 | el: "#my-custom-el", 83 | }, 84 | }); 85 | 86 | expect(document.querySelector(`#my-custom-el .single-spa-container`)).toBe( 87 | null 88 | ); 89 | 90 | return lifecycles 91 | .bootstrap(props) 92 | .then(() => lifecycles.mount(props)) 93 | .then(() => { 94 | expect( 95 | document.querySelector(`#my-custom-el .single-spa-container`) 96 | ).toBeTruthy(); 97 | 98 | document.querySelector("#my-custom-el").remove(); 99 | }); 100 | }); 101 | 102 | it(`uses the appOptions.el domElement (with id) if provided, and wraps the single-spa application in a container div`, () => { 103 | const domEl = Object.assign(document.createElement("div"), { 104 | id: "my-custom-el-2", 105 | }); 106 | 107 | document.body.appendChild(domEl); 108 | 109 | const lifecycles = new singleSpaVue({ 110 | Vue, 111 | appOptions: { 112 | el: domEl, 113 | }, 114 | }); 115 | 116 | expect( 117 | document.querySelector(`#my-custom-el-2 .single-spa-container`) 118 | ).toBe(null); 119 | 120 | return lifecycles 121 | .bootstrap(props) 122 | .then(() => lifecycles.mount(props)) 123 | .then(() => { 124 | expect(Vue).toHaveBeenCalled(); 125 | expect(Vue.mock.calls[0][0].el).toBe( 126 | "#my-custom-el-2 .single-spa-container" 127 | ); 128 | expect(Vue.mock.calls[0][0].data()).toEqual({ 129 | name: "test-app", 130 | }); 131 | }) 132 | .then(() => { 133 | expect( 134 | document.querySelector(`#my-custom-el-2 .single-spa-container`) 135 | ).toBeTruthy(); 136 | domEl.remove(); 137 | }); 138 | }); 139 | 140 | it(`uses the appOptions.el domElement (without id) if provided, and wraps the single-spa application in a container div`, () => { 141 | const domEl = document.createElement("div"); 142 | 143 | document.body.appendChild(domEl); 144 | 145 | const lifecycles = new singleSpaVue({ 146 | Vue, 147 | appOptions: { 148 | el: domEl, 149 | }, 150 | }); 151 | 152 | const htmlId = CSS.escape("single-spa-application:test-app"); 153 | 154 | return lifecycles 155 | .bootstrap(props) 156 | .then(() => lifecycles.mount(props)) 157 | .then(() => { 158 | expect(Vue.mock.calls[0][0].el).toBe( 159 | `#${htmlId} .single-spa-container` 160 | ); 161 | expect(Vue.mock.calls[0][0].data()).toEqual({ 162 | name: "test-app", 163 | }); 164 | }) 165 | .then(() => { 166 | expect( 167 | document.querySelector(`#${htmlId} .single-spa-container`) 168 | ).toBeTruthy(); 169 | domEl.remove(); 170 | }); 171 | }); 172 | 173 | it(`throws an error if appOptions.el is not passed in as a string or dom element`, () => { 174 | expect(() => { 175 | new singleSpaVue({ 176 | Vue, 177 | appOptions: { 178 | // `el` should be a string or DOM Element 179 | el: 1233, 180 | }, 181 | }); 182 | }).toThrow(/must be a string CSS selector/); 183 | }); 184 | 185 | it(`throws an error if appOptions.el doesn't exist in the dom`, () => { 186 | const lifecycles = new singleSpaVue({ 187 | Vue, 188 | appOptions: { 189 | el: "#doesnt-exist-in-dom", 190 | }, 191 | }); 192 | 193 | return lifecycles 194 | .bootstrap(props) 195 | .then(() => lifecycles.mount(props)) 196 | .then(() => { 197 | fail("should throw validation error"); 198 | }) 199 | .catch((err) => { 200 | expect(err.message).toMatch("the dom element must exist in the dom"); 201 | }); 202 | }); 203 | 204 | it(`reuses the default dom element container on the second mount`, () => { 205 | const lifecycles = new singleSpaVue({ 206 | Vue, 207 | appOptions: {}, 208 | }); 209 | 210 | expect(document.querySelectorAll(cssSelector).length).toBe(0); 211 | 212 | let firstEl; 213 | 214 | return lifecycles 215 | .bootstrap(props) 216 | .then(() => lifecycles.mount(props)) 217 | .then(() => { 218 | expect(document.querySelectorAll(cssSelector).length).toBe(1); 219 | firstEl = Vue.mock.calls[0].el; 220 | return lifecycles.unmount(props); 221 | }) 222 | .then(() => { 223 | expect(document.querySelectorAll(cssSelector).length).toBe(1); 224 | Vue.mockReset(); 225 | return lifecycles.mount(props); 226 | }) 227 | .then(() => { 228 | expect(document.querySelectorAll(cssSelector).length).toBe(1); 229 | let secondEl = Vue.mock.calls[0].el; 230 | expect(firstEl).toBe(secondEl); 231 | }); 232 | }); 233 | 234 | it(`passes appOptions straight through to Vue`, () => { 235 | const appOptions = { 236 | something: "random", 237 | }; 238 | const lifecycles = new singleSpaVue({ 239 | Vue, 240 | appOptions, 241 | }); 242 | 243 | return lifecycles 244 | .bootstrap(props) 245 | .then(() => lifecycles.mount(props)) 246 | .then(() => { 247 | expect(Vue).toHaveBeenCalled(); 248 | expect(Vue.mock.calls[0][0].something).toBeTruthy(); 249 | return lifecycles.unmount(props); 250 | }); 251 | }); 252 | 253 | it(`resolves appOptions from Promise and passes straight through to Vue`, () => { 254 | const appOptions = () => 255 | Promise.resolve({ 256 | something: "random", 257 | }); 258 | 259 | const lifecycles = new singleSpaVue({ 260 | Vue, 261 | appOptions, 262 | }); 263 | 264 | return lifecycles 265 | .bootstrap(props) 266 | .then(() => lifecycles.mount(props)) 267 | .then(() => { 268 | expect(Vue).toHaveBeenCalled(); 269 | expect(Vue.mock.calls[0][0].something).toBeTruthy(); 270 | return lifecycles.unmount(props); 271 | }); 272 | }); 273 | 274 | it(`appOptions function will recieve the props provided at mount`, () => { 275 | const appOptions = jest.fn((props) => 276 | Promise.resolve({ 277 | props, 278 | }) 279 | ); 280 | 281 | const lifecycles = new singleSpaVue({ 282 | Vue, 283 | appOptions, 284 | }); 285 | 286 | return lifecycles 287 | .bootstrap(props) 288 | .then(() => lifecycles.mount(props)) 289 | .then(() => { 290 | expect(appOptions.mock.calls[0][0]).toBe(props); 291 | return lifecycles.unmount(props); 292 | }); 293 | }); 294 | 295 | it("`handleInstance` function will recieve the props provided at mount", () => { 296 | const handleInstance = jest.fn(); 297 | const lifecycles = new singleSpaVue({ 298 | Vue, 299 | appOptions: {}, 300 | handleInstance, 301 | }); 302 | 303 | return lifecycles 304 | .bootstrap(props) 305 | .then(() => lifecycles.mount(props)) 306 | .then(() => { 307 | expect(handleInstance.mock.calls[0][1]).toBe(props); 308 | return lifecycles.unmount(props); 309 | }); 310 | }); 311 | 312 | it(`implements a render function for you if you provide loadRootComponent`, () => { 313 | const opts = { 314 | Vue, 315 | appOptions: {}, 316 | loadRootComponent: jest.fn(), 317 | }; 318 | 319 | opts.loadRootComponent.mockReturnValue(Promise.resolve({})); 320 | 321 | const lifecycles = new singleSpaVue(opts); 322 | 323 | return lifecycles 324 | .bootstrap(props) 325 | .then(() => { 326 | expect(opts.loadRootComponent).toHaveBeenCalled(); 327 | return lifecycles.mount(props); 328 | }) 329 | .then(() => { 330 | expect(Vue.mock.calls[0][0].render).toBeDefined(); 331 | return lifecycles.unmount(props); 332 | }); 333 | }); 334 | 335 | it(`adds the single-spa props as data to the root component`, () => { 336 | props.someCustomThing = "hi"; 337 | 338 | const lifecycles = new singleSpaVue({ 339 | Vue, 340 | appOptions: {}, 341 | }); 342 | 343 | return lifecycles 344 | .bootstrap(props) 345 | .then(() => lifecycles.mount(props)) 346 | .then(() => { 347 | expect(Vue).toHaveBeenCalled(); 348 | expect(Vue.mock.calls[0][0].data()).toBeTruthy(); 349 | expect(Vue.mock.calls[0][0].data().name).toBe("test-app"); 350 | expect(Vue.mock.calls[0][0].data().someCustomThing).toBe("hi"); 351 | return lifecycles.unmount(props); 352 | }); 353 | }); 354 | 355 | it(`mounts into the single-spa-container div if you don't provide an 'el' in appOptions`, () => { 356 | const lifecycles = new singleSpaVue({ 357 | Vue, 358 | appOptions: {}, 359 | }); 360 | 361 | return lifecycles 362 | .bootstrap(props) 363 | .then(() => lifecycles.mount(props)) 364 | .then(() => { 365 | expect(Vue).toHaveBeenCalled(); 366 | expect(Vue.mock.calls[0][0].el).toBe( 367 | cssSelector + " .single-spa-container" 368 | ); 369 | return lifecycles.unmount(props); 370 | }); 371 | }); 372 | 373 | it(`mounts will return promise with vue instance`, () => { 374 | const lifecycles = new singleSpaVue({ 375 | Vue, 376 | appOptions: {}, 377 | }); 378 | return lifecycles 379 | .bootstrap(props) 380 | .then(() => 381 | lifecycles.mount(props).then((instance) => { 382 | expect(Vue).toHaveBeenCalled(); 383 | expect(instance instanceof Vue).toBeTruthy(); 384 | }) 385 | ) 386 | .then(() => { 387 | return lifecycles.unmount(props); 388 | }); 389 | }); 390 | 391 | it(`mounts 2 instances and then unmounts them`, () => { 392 | const lifecycles = new singleSpaVue({ 393 | Vue, 394 | appOptions: {}, 395 | }); 396 | 397 | let obj1 = { 398 | props: props, 399 | spy: null, 400 | }; 401 | let obj2 = { 402 | props: { name: "test-app-2" }, 403 | spy: null, 404 | }; 405 | 406 | function mount(obj) { 407 | return lifecycles.mount(obj.props).then((instance) => { 408 | expect(instance instanceof Vue).toBeTruthy(); 409 | 410 | // since $destroy is always pointing to the same function (as it is defined it beforeEach()), 411 | // it is needed to be overwritten 412 | const oldDestroy = instance.$destroy; 413 | instance.$destroy = (...args) => { 414 | return oldDestroy.apply(instance, args); 415 | }; 416 | 417 | obj.spy = jest.spyOn(instance, "$destroy"); 418 | }); 419 | } 420 | 421 | function unmount(obj) { 422 | expect(obj.spy).not.toBeCalled(); 423 | return lifecycles.unmount(obj.props).then(() => { 424 | expect(obj.spy).toBeCalled(); 425 | }); 426 | } 427 | 428 | return lifecycles 429 | .bootstrap(props) 430 | .then(() => { 431 | return mount(obj1); 432 | }) 433 | .then(() => { 434 | return mount(obj2); 435 | }) 436 | .then(() => { 437 | return unmount(obj1); 438 | }) 439 | .then(() => { 440 | return unmount(obj2); 441 | }); 442 | }); 443 | 444 | it(`works with Vue 3 when you provide the full Vue module as an opt`, async () => { 445 | Vue = { 446 | createApp: jest.fn(), 447 | }; 448 | 449 | const appMock = jest.fn(); 450 | appMock.mount = jest.fn(); 451 | appMock.unmount = jest.fn(); 452 | 453 | window.appMock = appMock; 454 | 455 | Vue.createApp.mockReturnValue(appMock); 456 | 457 | const props = { name: "vue3-app" }; 458 | 459 | const handleInstance = jest.fn(); 460 | 461 | const lifecycles = new singleSpaVue({ 462 | Vue, 463 | appOptions: {}, 464 | handleInstance, 465 | }); 466 | 467 | await lifecycles.bootstrap(props); 468 | await lifecycles.mount(props); 469 | 470 | expect(Vue.createApp).toHaveBeenCalled(); 471 | // Vue 3 requires the data to be a function 472 | expect(typeof Vue.createApp.mock.calls[0][0].data).toBe("function"); 473 | expect(handleInstance).toHaveBeenCalledWith(appMock, props); 474 | expect(appMock.mount).toHaveBeenCalled(); 475 | 476 | await lifecycles.unmount(props); 477 | expect(appMock.unmount).toHaveBeenCalled(); 478 | }); 479 | 480 | it(`works with Vue 3 when you provide the createApp function opt`, async () => { 481 | const createApp = jest.fn(); 482 | 483 | const appMock = jest.fn(); 484 | appMock.mount = jest.fn(); 485 | appMock.unmount = jest.fn(); 486 | 487 | window.appMock = appMock; 488 | 489 | createApp.mockReturnValue(appMock); 490 | 491 | const props = { name: "vue3-app" }; 492 | 493 | const handleInstance = jest.fn(); 494 | 495 | const lifecycles = new singleSpaVue({ 496 | createApp, 497 | appOptions: {}, 498 | handleInstance, 499 | }); 500 | 501 | await lifecycles.bootstrap(props); 502 | await lifecycles.mount(props); 503 | 504 | expect(createApp).toHaveBeenCalled(); 505 | // Vue 3 requires the data to be a function 506 | expect(typeof createApp.mock.calls[0][0].data).toBe("function"); 507 | expect(handleInstance).toHaveBeenCalledWith(appMock, props); 508 | expect(appMock.mount).toHaveBeenCalled(); 509 | 510 | await lifecycles.unmount(props); 511 | expect(appMock.unmount).toHaveBeenCalled(); 512 | }); 513 | 514 | it(`support async handleInstance with creatApp to allow App resolve all children routes before rehydration`, async () => { 515 | const createApp = jest.fn(); 516 | 517 | const appMock = jest.fn(); 518 | appMock.mount = jest.fn(); 519 | appMock.unmount = jest.fn(); 520 | 521 | window.appMock = appMock; 522 | 523 | createApp.mockReturnValue(appMock); 524 | 525 | const props = { name: "vue3-app" }; 526 | 527 | let handleInstancePromise; 528 | 529 | const handleInstance = jest.fn(async () => { 530 | handleInstancePromise = new Promise((resolve) => { 531 | setTimeout(resolve); 532 | }); 533 | 534 | await handleInstancePromise; 535 | }); 536 | 537 | const lifecycles = new singleSpaVue({ 538 | createApp, 539 | appOptions: {}, 540 | handleInstance, 541 | }); 542 | 543 | await lifecycles.bootstrap(props); 544 | 545 | await lifecycles.mount(props); 546 | 547 | expect(handleInstance).toHaveBeenCalledWith(appMock, props); 548 | expect(createApp).toHaveBeenCalled(); 549 | // Vue 3 requires the data to be a function 550 | expect(typeof createApp.mock.calls[0][0].data).toBe("function"); 551 | 552 | expect(appMock.mount).toHaveBeenCalled(); 553 | 554 | await lifecycles.unmount(props); 555 | expect(appMock.unmount).toHaveBeenCalled(); 556 | }); 557 | 558 | it(`support async handleInstance without createApp to allow App resolve all children routes before rehydration`, async () => { 559 | let handleInstancePromise; 560 | 561 | const handleInstance = jest.fn(async () => { 562 | handleInstancePromise = new Promise((resolve) => { 563 | setTimeout(resolve); 564 | }); 565 | 566 | await handleInstancePromise; 567 | }); 568 | 569 | const lifecycles = new singleSpaVue({ 570 | Vue, 571 | appOptions: {}, 572 | handleInstance, 573 | }); 574 | 575 | await lifecycles.bootstrap(props); 576 | 577 | await lifecycles.mount(props); 578 | 579 | expect(handleInstance).toHaveBeenCalled(); 580 | 581 | await lifecycles.unmount(props); 582 | }); 583 | 584 | it(`mounts a Vue instance in specified element, if replaceMode is true`, () => { 585 | const domEl = document.createElement("div"); 586 | const htmlId = CSS.escape("single-spa-application:test-app"); 587 | 588 | document.body.appendChild(domEl); 589 | 590 | const lifecycles = new singleSpaVue({ 591 | Vue, 592 | appOptions: { 593 | el: domEl, 594 | }, 595 | replaceMode: true, 596 | }); 597 | 598 | return lifecycles 599 | .bootstrap(props) 600 | .then(() => lifecycles.mount(props)) 601 | .then(() => expect(Vue.mock.calls[0][0].el).toBe(`#${htmlId}`)) 602 | .then(() => { 603 | expect(document.querySelector(`#${htmlId}`)).toBeTruthy(); 604 | domEl.remove(); 605 | }); 606 | }); 607 | 608 | it(`mounts a Vue instance with ' .single-spa-container' if replaceMode is false or not provided`, () => { 609 | const domEl = document.createElement("div"); 610 | const htmlId = CSS.escape("single-spa-application:test-app"); 611 | 612 | document.body.appendChild(domEl); 613 | 614 | const lifecycles = new singleSpaVue({ 615 | Vue, 616 | appOptions: { 617 | el: domEl, 618 | }, 619 | }); 620 | 621 | return lifecycles 622 | .bootstrap(props) 623 | .then(() => lifecycles.mount(props)) 624 | .then(() => 625 | expect(Vue.mock.calls[0][0].el).toBe(`#${htmlId} .single-spa-container`) 626 | ) 627 | .then(() => { 628 | expect( 629 | document.querySelector(`#${htmlId} .single-spa-container`) 630 | ).toBeTruthy(); 631 | domEl.remove(); 632 | }); 633 | }); 634 | }); 635 | -------------------------------------------------------------------------------- /types/single-spa-vue.d.ts: -------------------------------------------------------------------------------- 1 | declare module "single-spa-vue" { 2 | export default function singleSpaVue( 3 | opts: SingleSpaVueOpts 4 | ): SingleSpaVueLifecycles; 5 | 6 | type AppOptions = { 7 | el?: string | HTMLElement; 8 | data?: any; 9 | [key: string]: any; 10 | }; 11 | 12 | interface BaseSingleSpaVueOptions { 13 | appOptions: 14 | | AppOptions 15 | | (( 16 | opts: SingleSpaOptsVue2 | SingleSpaOptsVue3, 17 | props: object 18 | ) => Promise); 19 | template?: string; 20 | loadRootComponent?(): Promise; 21 | } 22 | 23 | type SingleSpaOptsVue2 = BaseSingleSpaVueOptions & { 24 | Vue: any; 25 | }; 26 | 27 | type SingleSpaOptsVue3 = BaseSingleSpaVueOptions & { 28 | createApp(appOptions: AppOptions): any; 29 | handleInstance?(instance: any, props: object): void | Promise; 30 | replaceMode?: boolean; 31 | }; 32 | 33 | type SingleSpaVueOpts = SingleSpaOptsVue2 | SingleSpaOptsVue3; 34 | 35 | type SingleSpaVueLifecycles = { 36 | bootstrap(singleSpaProps: SingleSpaProps): Promise; 37 | mount(singleSpaProps: SingleSpaProps): Promise; 38 | unmount(singleSpaProps: SingleSpaProps): Promise; 39 | update(singleSpaProps: SingleSpaProps): Promise; 40 | }; 41 | 42 | type SingleSpaProps = object; 43 | } 44 | -------------------------------------------------------------------------------- /types/single-spa-vue.test-d.ts: -------------------------------------------------------------------------------- 1 | import ".."; 2 | import singleSpaVue, { 3 | SingleSpaVueOpts, 4 | SingleSpaOptsVue2, 5 | SingleSpaOptsVue3, 6 | } from "single-spa-vue"; 7 | import { expectAssignable } from "tsd"; 8 | 9 | const Vue = () => {}; 10 | const appOptions = {}; 11 | 12 | const optsVue2: SingleSpaOptsVue2 = { 13 | Vue, 14 | appOptions, 15 | }; 16 | expectAssignable(optsVue2); 17 | singleSpaVue(optsVue2); 18 | 19 | const createApp = () => {}; 20 | const optsVue3: SingleSpaOptsVue3 = { 21 | createApp, 22 | appOptions: (opts: object, props: object) => Promise.resolve(), 23 | handleInstance: (instance: any, props: object) => {}, 24 | }; 25 | expectAssignable(optsVue3); 26 | singleSpaVue(optsVue3); 27 | --------------------------------------------------------------------------------