Skip to content

Commit 1ec7a40

Browse files
committed
refactor: replace string selectors with HTMLElements for single-spa mount
The mount function was using a string selector to mount the Vue instance. In order to add the possibility to mount inside a ShadowDom, we cannot use string selectors. So we replace the logic to be based on HTMLElements.
1 parent 614f5e3 commit 1ec7a40

File tree

2 files changed

+78
-18
lines changed

2 files changed

+78
-18
lines changed

src/single-spa-vue.js

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -88,14 +88,17 @@ function mount(opts, mountedInstances, props) {
8888
}
8989
} else {
9090
domEl = appOptions.el;
91+
if (!domEl.parentNode) {
92+
throw Error(
93+
`If appOptions.el is provided to single-spa-vue, the dom element must exist in the dom. Was provided as ${appOptions.el.outerHTML}`
94+
);
95+
}
9196
if (!domEl.id) {
9297
domEl.id = `single-spa-application:${props.name}`;
9398
}
94-
appOptions.el = `#${CSS.escape(domEl.id)}`;
9599
}
96100
} else {
97101
const htmlId = `single-spa-application:${props.name}`;
98-
appOptions.el = `#${CSS.escape(htmlId)}`;
99102
domEl = document.getElementById(htmlId);
100103
if (!domEl) {
101104
domEl = document.createElement("div");
@@ -104,10 +107,6 @@ function mount(opts, mountedInstances, props) {
104107
}
105108
}
106109

107-
if (!opts.replaceMode) {
108-
appOptions.el = appOptions.el + " .single-spa-container";
109-
}
110-
111110
// single-spa-vue@>=2 always REPLACES the `el` instead of appending to it.
112111
// We want domEl to stick around and not be replaced. So we tell Vue to mount
113112
// into a container div inside of the main domEl
@@ -119,6 +118,12 @@ function mount(opts, mountedInstances, props) {
119118

120119
instance.domEl = domEl;
121120

121+
if (!opts.replaceMode) {
122+
domEl = domEl.querySelector(".single-spa-container");
123+
}
124+
125+
appOptions.el = domEl;
126+
122127
if (!appOptions.render && !appOptions.template && opts.rootComponent) {
123128
appOptions.render = (h) => h(opts.rootComponent);
124129
}

src/single-spa-vue.test.js

Lines changed: 67 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,13 @@ import singleSpaVue from "./single-spa-vue";
33
const domElId = `single-spa-application:test-app`;
44
const cssSelector = `#single-spa-application\\:test-app`;
55

6+
const singleSpaContainerDiv = document.createElement("div");
7+
singleSpaContainerDiv.className = "single-spa-container";
8+
9+
const singleSpaApplicationDiv = document.createElement("div");
10+
singleSpaApplicationDiv.id = domElId;
11+
singleSpaApplicationDiv.append(singleSpaContainerDiv);
12+
613
describe("single-spa-vue", () => {
714
let Vue, props, $destroy;
815

@@ -122,9 +129,7 @@ describe("single-spa-vue", () => {
122129
.then(() => lifecycles.mount(props))
123130
.then(() => {
124131
expect(Vue).toHaveBeenCalled();
125-
expect(Vue.mock.calls[0][0].el).toBe(
126-
"#my-custom-el-2 .single-spa-container"
127-
);
132+
expect(Vue.mock.calls[0][0].el).toEqual(singleSpaContainerDiv);
128133
expect(Vue.mock.calls[0][0].data()).toEqual({
129134
name: "test-app",
130135
});
@@ -155,9 +160,7 @@ describe("single-spa-vue", () => {
155160
.bootstrap(props)
156161
.then(() => lifecycles.mount(props))
157162
.then(() => {
158-
expect(Vue.mock.calls[0][0].el).toBe(
159-
`#${htmlId} .single-spa-container`
160-
);
163+
expect(Vue.mock.calls[0][0].el).toEqual(singleSpaContainerDiv);
161164
expect(Vue.mock.calls[0][0].data()).toEqual({
162165
name: "test-app",
163166
});
@@ -182,7 +185,7 @@ describe("single-spa-vue", () => {
182185
}).toThrow(/must be a string CSS selector/);
183186
});
184187

185-
it(`throws an error if appOptions.el doesn't exist in the dom`, () => {
188+
it(`throws an error if appOptions.el as string selector doesn't exist in the dom`, () => {
186189
const lifecycles = new singleSpaVue({
187190
Vue,
188191
appOptions: {
@@ -201,6 +204,26 @@ describe("single-spa-vue", () => {
201204
});
202205
});
203206

207+
it(`throws an error if appOptions.el as HTMLElement doesn't exist in the dom`, () => {
208+
const doesntExistInDom = document.createElement("div");
209+
const lifecycles = new singleSpaVue({
210+
Vue,
211+
appOptions: {
212+
el: doesntExistInDom,
213+
},
214+
});
215+
216+
return lifecycles
217+
.bootstrap(props)
218+
.then(() => lifecycles.mount(props))
219+
.then(() => {
220+
fail("should throw validation error");
221+
})
222+
.catch((err) => {
223+
expect(err.message).toMatch("the dom element must exist in the dom");
224+
});
225+
});
226+
204227
it(`reuses the default dom element container on the second mount`, () => {
205228
const lifecycles = new singleSpaVue({
206229
Vue,
@@ -363,9 +386,7 @@ describe("single-spa-vue", () => {
363386
.then(() => lifecycles.mount(props))
364387
.then(() => {
365388
expect(Vue).toHaveBeenCalled();
366-
expect(Vue.mock.calls[0][0].el).toBe(
367-
cssSelector + " .single-spa-container"
368-
);
389+
expect(Vue.mock.calls[0][0].el).toEqual(singleSpaContainerDiv);
369390
return lifecycles.unmount(props);
370391
});
371392
});
@@ -598,7 +619,9 @@ describe("single-spa-vue", () => {
598619
return lifecycles
599620
.bootstrap(props)
600621
.then(() => lifecycles.mount(props))
601-
.then(() => expect(Vue.mock.calls[0][0].el).toBe(`#${htmlId}`))
622+
.then(() =>
623+
expect(Vue.mock.calls[0][0].el).toEqual(singleSpaApplicationDiv)
624+
)
602625
.then(() => {
603626
expect(document.querySelector(`#${htmlId}`)).toBeTruthy();
604627
domEl.remove();
@@ -622,7 +645,7 @@ describe("single-spa-vue", () => {
622645
.bootstrap(props)
623646
.then(() => lifecycles.mount(props))
624647
.then(() =>
625-
expect(Vue.mock.calls[0][0].el).toBe(`#${htmlId} .single-spa-container`)
648+
expect(Vue.mock.calls[0][0].el).toEqual(singleSpaContainerDiv)
626649
)
627650
.then(() => {
628651
expect(
@@ -631,4 +654,36 @@ describe("single-spa-vue", () => {
631654
domEl.remove();
632655
});
633656
});
657+
658+
it(`mounts into a shadow dom`, () => {
659+
const domEl = document.createElement("div");
660+
domEl.attachShadow({ mode: "open" });
661+
662+
const shadowMount = document.createElement("div");
663+
domEl.shadowRoot.append(shadowMount);
664+
665+
const htmlId = CSS.escape("single-spa-application:test-app");
666+
667+
document.body.appendChild(domEl);
668+
669+
const lifecycles = new singleSpaVue({
670+
Vue,
671+
appOptions: {
672+
el: shadowMount,
673+
},
674+
});
675+
676+
return lifecycles
677+
.bootstrap(props)
678+
.then(() => lifecycles.mount(props))
679+
.then(() =>
680+
expect(Vue.mock.calls[0][0].el).toEqual(singleSpaContainerDiv)
681+
)
682+
.then(() => {
683+
expect(
684+
domEl.shadowRoot.querySelector(`#${htmlId} .single-spa-container`)
685+
).toBeTruthy();
686+
domEl.remove();
687+
});
688+
});
634689
});

0 commit comments

Comments
 (0)