Skip to content

Commit 3946f1f

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 46310de commit 3946f1f

File tree

2 files changed

+74
-16
lines changed

2 files changed

+74
-16
lines changed

src/single-spa-vue.js

Lines changed: 8 additions & 4 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");
@@ -105,8 +108,6 @@ function mount(opts, mountedInstances, props) {
105108
}
106109

107110
if (!opts.replaceMode) {
108-
appOptions.el = appOptions.el + " .single-spa-container";
109-
110111
// single-spa-vue@>=2 always REPLACES the `el` instead of appending to it.
111112
// We want domEl to stick around and not be replaced. So we tell Vue to mount
112113
// into a container div inside of the main domEl
@@ -115,8 +116,11 @@ function mount(opts, mountedInstances, props) {
115116
singleSpaContainer.className = "single-spa-container";
116117
domEl.appendChild(singleSpaContainer);
117118
}
119+
120+
domEl = domEl.querySelector(".single-spa-container");
118121
}
119122

123+
appOptions.el = domEl;
120124
instance.domEl = domEl;
121125

122126
if (!appOptions.render && !appOptions.template && opts.rootComponent) {

src/single-spa-vue.test.js

Lines changed: 66 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@ 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+
612
describe("single-spa-vue", () => {
713
let Vue, props, $destroy;
814

@@ -122,9 +128,7 @@ describe("single-spa-vue", () => {
122128
.then(() => lifecycles.mount(props))
123129
.then(() => {
124130
expect(Vue).toHaveBeenCalled();
125-
expect(Vue.mock.calls[0][0].el).toBe(
126-
"#my-custom-el-2 .single-spa-container"
127-
);
131+
expect(Vue.mock.calls[0][0].el).toEqual(singleSpaContainerDiv);
128132
expect(Vue.mock.calls[0][0].data()).toEqual({
129133
name: "test-app",
130134
});
@@ -155,9 +159,7 @@ describe("single-spa-vue", () => {
155159
.bootstrap(props)
156160
.then(() => lifecycles.mount(props))
157161
.then(() => {
158-
expect(Vue.mock.calls[0][0].el).toBe(
159-
`#${htmlId} .single-spa-container`
160-
);
162+
expect(Vue.mock.calls[0][0].el).toEqual(singleSpaContainerDiv);
161163
expect(Vue.mock.calls[0][0].data()).toEqual({
162164
name: "test-app",
163165
});
@@ -182,7 +184,7 @@ describe("single-spa-vue", () => {
182184
}).toThrow(/must be a string CSS selector/);
183185
});
184186

185-
it(`throws an error if appOptions.el doesn't exist in the dom`, () => {
187+
it(`throws an error if appOptions.el as string selector doesn't exist in the dom`, () => {
186188
const lifecycles = new singleSpaVue({
187189
Vue,
188190
appOptions: {
@@ -201,6 +203,26 @@ describe("single-spa-vue", () => {
201203
});
202204
});
203205

206+
it(`throws an error if appOptions.el as HTMLElement doesn't exist in the dom`, () => {
207+
const doesntExistInDom = document.createElement("div");
208+
const lifecycles = new singleSpaVue({
209+
Vue,
210+
appOptions: {
211+
el: doesntExistInDom,
212+
},
213+
});
214+
215+
return lifecycles
216+
.bootstrap(props)
217+
.then(() => lifecycles.mount(props))
218+
.then(() => {
219+
fail("should throw validation error");
220+
})
221+
.catch((err) => {
222+
expect(err.message).toMatch("the dom element must exist in the dom");
223+
});
224+
});
225+
204226
it(`reuses the default dom element container on the second mount`, () => {
205227
const lifecycles = new singleSpaVue({
206228
Vue,
@@ -363,9 +385,7 @@ describe("single-spa-vue", () => {
363385
.then(() => lifecycles.mount(props))
364386
.then(() => {
365387
expect(Vue).toHaveBeenCalled();
366-
expect(Vue.mock.calls[0][0].el).toBe(
367-
cssSelector + " .single-spa-container"
368-
);
388+
expect(Vue.mock.calls[0][0].el).toEqual(singleSpaContainerDiv);
369389
return lifecycles.unmount(props);
370390
});
371391
});
@@ -598,7 +618,9 @@ describe("single-spa-vue", () => {
598618
return lifecycles
599619
.bootstrap(props)
600620
.then(() => lifecycles.mount(props))
601-
.then(() => expect(Vue.mock.calls[0][0].el).toBe(`#${htmlId}`))
621+
.then(() =>
622+
expect(Vue.mock.calls[0][0].el).toEqual(singleSpaApplicationDiv)
623+
)
602624
.then(() => {
603625
expect(document.querySelector(`#${htmlId}`)).toBeTruthy();
604626
domEl.remove();
@@ -622,7 +644,7 @@ describe("single-spa-vue", () => {
622644
.bootstrap(props)
623645
.then(() => lifecycles.mount(props))
624646
.then(() =>
625-
expect(Vue.mock.calls[0][0].el).toBe(`#${htmlId} .single-spa-container`)
647+
expect(Vue.mock.calls[0][0].el).toEqual(singleSpaContainerDiv)
626648
)
627649
.then(() => {
628650
expect(
@@ -631,4 +653,36 @@ describe("single-spa-vue", () => {
631653
domEl.remove();
632654
});
633655
});
656+
657+
it(`mounts into a shadow dom`, () => {
658+
const domEl = document.createElement("div");
659+
domEl.attachShadow({ mode: "open" });
660+
661+
const shadowMount = document.createElement("div");
662+
domEl.shadowRoot.append(shadowMount);
663+
664+
const htmlId = CSS.escape("single-spa-application:test-app");
665+
666+
document.body.appendChild(domEl);
667+
668+
const lifecycles = new singleSpaVue({
669+
Vue,
670+
appOptions: {
671+
el: shadowMount,
672+
},
673+
});
674+
675+
return lifecycles
676+
.bootstrap(props)
677+
.then(() => lifecycles.mount(props))
678+
.then(() =>
679+
expect(Vue.mock.calls[0][0].el).toEqual(singleSpaContainerDiv)
680+
)
681+
.then(() => {
682+
expect(
683+
domEl.shadowRoot.querySelector(`#${htmlId} .single-spa-container`)
684+
).toBeTruthy();
685+
domEl.remove();
686+
});
687+
});
634688
});

0 commit comments

Comments
 (0)