customElement.spec.ts 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035
  1. import type { MockedFunction } from 'vitest'
  2. import {
  3. type HMRRuntime,
  4. type Ref,
  5. type VueElement,
  6. createApp,
  7. defineAsyncComponent,
  8. defineComponent,
  9. defineCustomElement,
  10. h,
  11. inject,
  12. nextTick,
  13. ref,
  14. render,
  15. renderSlot,
  16. useShadowRoot,
  17. } from '../src'
  18. declare var __VUE_HMR_RUNTIME__: HMRRuntime
  19. describe('defineCustomElement', () => {
  20. const container = document.createElement('div')
  21. document.body.appendChild(container)
  22. beforeEach(() => {
  23. container.innerHTML = ''
  24. })
  25. describe('mounting/unmount', () => {
  26. const E = defineCustomElement({
  27. props: {
  28. msg: {
  29. type: String,
  30. default: 'hello',
  31. },
  32. },
  33. render() {
  34. return h('div', this.msg)
  35. },
  36. })
  37. customElements.define('my-element', E)
  38. test('should work', () => {
  39. container.innerHTML = `<my-element></my-element>`
  40. const e = container.childNodes[0] as VueElement
  41. expect(e).toBeInstanceOf(E)
  42. expect(e._instance).toBeTruthy()
  43. expect(e.shadowRoot!.innerHTML).toBe(`<div>hello</div>`)
  44. })
  45. test('should work w/ manual instantiation', () => {
  46. const e = new E({ msg: 'inline' })
  47. // should lazy init
  48. expect(e._instance).toBe(null)
  49. // should initialize on connect
  50. container.appendChild(e)
  51. expect(e._instance).toBeTruthy()
  52. expect(e.shadowRoot!.innerHTML).toBe(`<div>inline</div>`)
  53. })
  54. test('should unmount on remove', async () => {
  55. container.innerHTML = `<my-element></my-element>`
  56. const e = container.childNodes[0] as VueElement
  57. container.removeChild(e)
  58. await nextTick()
  59. expect(e._instance).toBe(null)
  60. expect(e.shadowRoot!.innerHTML).toBe('')
  61. })
  62. // #10610
  63. test('When elements move, avoid prematurely disconnecting MutationObserver', async () => {
  64. const CustomInput = defineCustomElement({
  65. props: ['value'],
  66. emits: ['update'],
  67. setup(props, { emit }) {
  68. return () =>
  69. h('input', {
  70. type: 'number',
  71. value: props.value,
  72. onInput: (e: InputEvent) => {
  73. const num = (e.target! as HTMLInputElement).valueAsNumber
  74. emit('update', Number.isNaN(num) ? null : num)
  75. },
  76. })
  77. },
  78. })
  79. customElements.define('my-el-input', CustomInput)
  80. const num = ref('12')
  81. const containerComp = defineComponent({
  82. setup() {
  83. return () => {
  84. return h('div', [
  85. h('my-el-input', {
  86. value: num.value,
  87. onUpdate: ($event: CustomEvent) => {
  88. num.value = $event.detail[0]
  89. },
  90. }),
  91. h('div', { id: 'move' }),
  92. ])
  93. }
  94. },
  95. })
  96. const app = createApp(containerComp)
  97. app.mount(container)
  98. const myInputEl = container.querySelector('my-el-input')!
  99. const inputEl = myInputEl.shadowRoot!.querySelector('input')!
  100. await nextTick()
  101. expect(inputEl.value).toBe('12')
  102. const moveEl = container.querySelector('#move')!
  103. moveEl.append(myInputEl)
  104. await nextTick()
  105. myInputEl.removeAttribute('value')
  106. await nextTick()
  107. expect(inputEl.value).toBe('')
  108. })
  109. test('should not unmount on move', async () => {
  110. container.innerHTML = `<div><my-element></my-element></div>`
  111. const e = container.childNodes[0].childNodes[0] as VueElement
  112. const i = e._instance
  113. // moving from one parent to another - this will trigger both disconnect
  114. // and connected callbacks synchronously
  115. container.appendChild(e)
  116. await nextTick()
  117. // should be the same instance
  118. expect(e._instance).toBe(i)
  119. expect(e.shadowRoot!.innerHTML).toBe('<div>hello</div>')
  120. })
  121. test('remove then insert again', async () => {
  122. container.innerHTML = `<my-element></my-element>`
  123. const e = container.childNodes[0] as VueElement
  124. container.removeChild(e)
  125. await nextTick()
  126. expect(e._instance).toBe(null)
  127. expect(e.shadowRoot!.innerHTML).toBe('')
  128. container.appendChild(e)
  129. expect(e._instance).toBeTruthy()
  130. expect(e.shadowRoot!.innerHTML).toBe('<div>hello</div>')
  131. })
  132. })
  133. describe('props', () => {
  134. const E = defineCustomElement({
  135. props: {
  136. foo: [String, null],
  137. bar: Object,
  138. bazQux: null,
  139. value: null,
  140. },
  141. render() {
  142. return [
  143. h('div', null, this.foo || ''),
  144. h('div', null, this.bazQux || (this.bar && this.bar.x)),
  145. ]
  146. },
  147. })
  148. customElements.define('my-el-props', E)
  149. test('renders custom element w/ correct object prop value', () => {
  150. render(h('my-el-props', { value: { x: 1 } }), container)
  151. const el = container.children[0]
  152. expect((el as any).value).toEqual({ x: 1 })
  153. })
  154. test('props via attribute', async () => {
  155. // bazQux should map to `baz-qux` attribute
  156. container.innerHTML = `<my-el-props foo="hello" baz-qux="bye"></my-el-props>`
  157. const e = container.childNodes[0] as VueElement
  158. expect(e.shadowRoot!.innerHTML).toBe('<div>hello</div><div>bye</div>')
  159. // change attr
  160. e.setAttribute('foo', 'changed')
  161. await nextTick()
  162. expect(e.shadowRoot!.innerHTML).toBe('<div>changed</div><div>bye</div>')
  163. e.setAttribute('baz-qux', 'changed')
  164. await nextTick()
  165. expect(e.shadowRoot!.innerHTML).toBe(
  166. '<div>changed</div><div>changed</div>',
  167. )
  168. })
  169. test('props via properties', async () => {
  170. const e = new E()
  171. e.foo = 'one'
  172. e.bar = { x: 'two' }
  173. container.appendChild(e)
  174. expect(e.shadowRoot!.innerHTML).toBe('<div>one</div><div>two</div>')
  175. // reflect
  176. // should reflect primitive value
  177. expect(e.getAttribute('foo')).toBe('one')
  178. // should not reflect rich data
  179. expect(e.hasAttribute('bar')).toBe(false)
  180. e.foo = 'three'
  181. await nextTick()
  182. expect(e.shadowRoot!.innerHTML).toBe('<div>three</div><div>two</div>')
  183. expect(e.getAttribute('foo')).toBe('three')
  184. e.foo = null
  185. await nextTick()
  186. expect(e.shadowRoot!.innerHTML).toBe('<div></div><div>two</div>')
  187. expect(e.hasAttribute('foo')).toBe(false)
  188. e.foo = undefined
  189. await nextTick()
  190. expect(e.shadowRoot!.innerHTML).toBe('<div></div><div>two</div>')
  191. expect(e.hasAttribute('foo')).toBe(false)
  192. expect(e.foo).toBe(undefined)
  193. e.bazQux = 'four'
  194. await nextTick()
  195. expect(e.shadowRoot!.innerHTML).toBe('<div></div><div>four</div>')
  196. expect(e.getAttribute('baz-qux')).toBe('four')
  197. })
  198. test('attribute -> prop type casting', async () => {
  199. const E = defineCustomElement({
  200. props: {
  201. fooBar: Number, // test casting of camelCase prop names
  202. bar: Boolean,
  203. baz: String,
  204. },
  205. render() {
  206. return [
  207. this.fooBar,
  208. typeof this.fooBar,
  209. this.bar,
  210. typeof this.bar,
  211. this.baz,
  212. typeof this.baz,
  213. ].join(' ')
  214. },
  215. })
  216. customElements.define('my-el-props-cast', E)
  217. container.innerHTML = `<my-el-props-cast foo-bar="1" baz="12345"></my-el-props-cast>`
  218. const e = container.childNodes[0] as VueElement
  219. expect(e.shadowRoot!.innerHTML).toBe(
  220. `1 number false boolean 12345 string`,
  221. )
  222. e.setAttribute('bar', '')
  223. await nextTick()
  224. expect(e.shadowRoot!.innerHTML).toBe(`1 number true boolean 12345 string`)
  225. e.setAttribute('foo-bar', '2e1')
  226. await nextTick()
  227. expect(e.shadowRoot!.innerHTML).toBe(
  228. `20 number true boolean 12345 string`,
  229. )
  230. e.setAttribute('baz', '2e1')
  231. await nextTick()
  232. expect(e.shadowRoot!.innerHTML).toBe(`20 number true boolean 2e1 string`)
  233. })
  234. // #4772
  235. test('attr casting w/ programmatic creation', () => {
  236. const E = defineCustomElement({
  237. props: {
  238. foo: Number,
  239. },
  240. render() {
  241. return `foo type: ${typeof this.foo}`
  242. },
  243. })
  244. customElements.define('my-element-programmatic', E)
  245. const el = document.createElement('my-element-programmatic') as any
  246. el.setAttribute('foo', '123')
  247. container.appendChild(el)
  248. expect(el.shadowRoot.innerHTML).toBe(`foo type: number`)
  249. })
  250. test('handling properties set before upgrading', () => {
  251. const E = defineCustomElement({
  252. props: {
  253. foo: String,
  254. dataAge: Number,
  255. },
  256. setup(props) {
  257. expect(props.foo).toBe('hello')
  258. expect(props.dataAge).toBe(5)
  259. },
  260. render() {
  261. return h('div', `foo: ${this.foo}`)
  262. },
  263. })
  264. const el = document.createElement('my-el-upgrade') as any
  265. el.foo = 'hello'
  266. el.dataset.age = 5
  267. el.notProp = 1
  268. container.appendChild(el)
  269. customElements.define('my-el-upgrade', E)
  270. expect(el.shadowRoot.firstChild.innerHTML).toBe(`foo: hello`)
  271. // should not reflect if not declared as a prop
  272. expect(el.hasAttribute('not-prop')).toBe(false)
  273. })
  274. test('handle properties set before connecting', () => {
  275. const obj = { a: 1 }
  276. const E = defineCustomElement({
  277. props: {
  278. foo: String,
  279. post: Object,
  280. },
  281. setup(props) {
  282. expect(props.foo).toBe('hello')
  283. expect(props.post).toBe(obj)
  284. },
  285. render() {
  286. return JSON.stringify(this.post)
  287. },
  288. })
  289. customElements.define('my-el-preconnect', E)
  290. const el = document.createElement('my-el-preconnect') as any
  291. el.foo = 'hello'
  292. el.post = obj
  293. container.appendChild(el)
  294. expect(el.shadowRoot.innerHTML).toBe(JSON.stringify(obj))
  295. })
  296. // https://github.com/vuejs/core/issues/6163
  297. test('handle components with no props', async () => {
  298. const E = defineCustomElement({
  299. render() {
  300. return h('div', 'foo')
  301. },
  302. })
  303. customElements.define('my-element-noprops', E)
  304. const el = document.createElement('my-element-noprops')
  305. container.appendChild(el)
  306. await nextTick()
  307. expect(el.shadowRoot!.innerHTML).toMatchInlineSnapshot('"<div>foo</div>"')
  308. })
  309. // # 5793
  310. test('set number value in dom property', () => {
  311. const E = defineCustomElement({
  312. props: {
  313. 'max-age': Number,
  314. },
  315. render() {
  316. // @ts-expect-error
  317. return `max age: ${this.maxAge}/type: ${typeof this.maxAge}`
  318. },
  319. })
  320. customElements.define('my-element-number-property', E)
  321. const el = document.createElement('my-element-number-property') as any
  322. container.appendChild(el)
  323. el.maxAge = 50
  324. expect(el.maxAge).toBe(50)
  325. expect(el.shadowRoot.innerHTML).toBe('max age: 50/type: number')
  326. })
  327. test('support direct setup function syntax with extra options', () => {
  328. const E = defineCustomElement(
  329. props => {
  330. return () => props.text
  331. },
  332. {
  333. props: {
  334. text: String,
  335. },
  336. },
  337. )
  338. customElements.define('my-el-setup-with-props', E)
  339. container.innerHTML = `<my-el-setup-with-props text="hello"></my-el-setup-with-props>`
  340. const e = container.childNodes[0] as VueElement
  341. expect(e.shadowRoot!.innerHTML).toBe('hello')
  342. })
  343. })
  344. describe('attrs', () => {
  345. const E = defineCustomElement({
  346. render() {
  347. return [h('div', null, this.$attrs.foo as string)]
  348. },
  349. })
  350. customElements.define('my-el-attrs', E)
  351. test('attrs via attribute', async () => {
  352. container.innerHTML = `<my-el-attrs foo="hello"></my-el-attrs>`
  353. const e = container.childNodes[0] as VueElement
  354. expect(e.shadowRoot!.innerHTML).toBe('<div>hello</div>')
  355. e.setAttribute('foo', 'changed')
  356. await nextTick()
  357. expect(e.shadowRoot!.innerHTML).toBe('<div>changed</div>')
  358. })
  359. test('non-declared properties should not show up in $attrs', () => {
  360. const e = new E()
  361. // @ts-expect-error
  362. e.foo = '123'
  363. container.appendChild(e)
  364. expect(e.shadowRoot!.innerHTML).toBe('<div></div>')
  365. })
  366. })
  367. describe('emits', () => {
  368. const CompDef = defineComponent({
  369. setup(_, { emit }) {
  370. emit('created')
  371. return () =>
  372. h('div', {
  373. onClick: () => {
  374. emit('my-click', 1)
  375. },
  376. onMousedown: () => {
  377. emit('myEvent', 1) // validate hyphenation
  378. },
  379. onWheel: () => {
  380. emit('my-wheel', { bubbles: true }, 1)
  381. },
  382. })
  383. },
  384. })
  385. const E = defineCustomElement(CompDef)
  386. customElements.define('my-el-emits', E)
  387. test('emit on connect', () => {
  388. const e = new E()
  389. const spy = vi.fn()
  390. e.addEventListener('created', spy)
  391. container.appendChild(e)
  392. expect(spy).toHaveBeenCalled()
  393. })
  394. test('emit on interaction', () => {
  395. container.innerHTML = `<my-el-emits></my-el-emits>`
  396. const e = container.childNodes[0] as VueElement
  397. const spy = vi.fn()
  398. e.addEventListener('my-click', spy)
  399. e.shadowRoot!.childNodes[0].dispatchEvent(new CustomEvent('click'))
  400. expect(spy).toHaveBeenCalledTimes(1)
  401. expect(spy.mock.calls[0][0]).toMatchObject({
  402. detail: [1],
  403. })
  404. })
  405. // #5373
  406. test('case transform for camelCase event', () => {
  407. container.innerHTML = `<my-el-emits></my-el-emits>`
  408. const e = container.childNodes[0] as VueElement
  409. const spy1 = vi.fn()
  410. e.addEventListener('myEvent', spy1)
  411. const spy2 = vi.fn()
  412. // emitting myEvent, but listening for my-event. This happens when
  413. // using the custom element in a Vue template
  414. e.addEventListener('my-event', spy2)
  415. e.shadowRoot!.childNodes[0].dispatchEvent(new CustomEvent('mousedown'))
  416. expect(spy1).toHaveBeenCalledTimes(1)
  417. expect(spy2).toHaveBeenCalledTimes(1)
  418. })
  419. test('emit from within async component wrapper', async () => {
  420. const p = new Promise<typeof CompDef>(res => res(CompDef as any))
  421. const E = defineCustomElement(defineAsyncComponent(() => p))
  422. customElements.define('my-async-el-emits', E)
  423. container.innerHTML = `<my-async-el-emits></my-async-el-emits>`
  424. const e = container.childNodes[0] as VueElement
  425. const spy = vi.fn()
  426. e.addEventListener('my-click', spy)
  427. // this feels brittle but seems necessary to reach the node in the DOM.
  428. await customElements.whenDefined('my-async-el-emits')
  429. await nextTick()
  430. await nextTick()
  431. e.shadowRoot!.childNodes[0].dispatchEvent(new CustomEvent('click'))
  432. expect(spy).toHaveBeenCalled()
  433. expect(spy.mock.calls[0][0]).toMatchObject({
  434. detail: [1],
  435. })
  436. })
  437. // #7293
  438. test('emit in an async component wrapper with properties bound', async () => {
  439. const E = defineCustomElement(
  440. defineAsyncComponent(
  441. () => new Promise<typeof CompDef>(res => res(CompDef as any)),
  442. ),
  443. )
  444. customElements.define('my-async-el-props-emits', E)
  445. container.innerHTML = `<my-async-el-props-emits id="my_async_el_props_emits"></my-async-el-props-emits>`
  446. const e = container.childNodes[0] as VueElement
  447. const spy = vi.fn()
  448. e.addEventListener('my-click', spy)
  449. await customElements.whenDefined('my-async-el-props-emits')
  450. await nextTick()
  451. await nextTick()
  452. e.shadowRoot!.childNodes[0].dispatchEvent(new CustomEvent('click'))
  453. expect(spy).toHaveBeenCalled()
  454. expect(spy.mock.calls[0][0]).toMatchObject({
  455. detail: [1],
  456. })
  457. })
  458. test('emit with options', async () => {
  459. container.innerHTML = `<my-el-emits></my-el-emits>`
  460. const e = container.childNodes[0] as VueElement
  461. const spy = vi.fn()
  462. e.addEventListener('my-wheel', spy)
  463. e.shadowRoot!.childNodes[0].dispatchEvent(new CustomEvent('wheel'))
  464. expect(spy).toHaveBeenCalledTimes(1)
  465. expect(spy.mock.calls[0][0]).toMatchObject({
  466. bubbles: true,
  467. detail: [{ bubbles: true }, 1],
  468. })
  469. })
  470. })
  471. describe('slots', () => {
  472. const E = defineCustomElement({
  473. render() {
  474. return [
  475. h('div', null, [
  476. renderSlot(this.$slots, 'default', undefined, () => [
  477. h('div', 'fallback'),
  478. ]),
  479. ]),
  480. h('div', null, renderSlot(this.$slots, 'named')),
  481. ]
  482. },
  483. })
  484. customElements.define('my-el-slots', E)
  485. test('render slots correctly', () => {
  486. container.innerHTML = `<my-el-slots><span>hi</span></my-el-slots>`
  487. const e = container.childNodes[0] as VueElement
  488. // native slots allocation does not affect innerHTML, so we just
  489. // verify that we've rendered the correct native slots here...
  490. expect(e.shadowRoot!.innerHTML).toBe(
  491. `<div><slot><div>fallback</div></slot></div><div><slot name="named"></slot></div>`,
  492. )
  493. })
  494. })
  495. describe('provide/inject', () => {
  496. const Consumer = defineCustomElement({
  497. setup() {
  498. const foo = inject<Ref>('foo')!
  499. return () => h('div', foo.value)
  500. },
  501. })
  502. customElements.define('my-consumer', Consumer)
  503. test('over nested usage', async () => {
  504. const foo = ref('injected!')
  505. const Provider = defineCustomElement({
  506. provide: {
  507. foo,
  508. },
  509. render() {
  510. return h('my-consumer')
  511. },
  512. })
  513. customElements.define('my-provider', Provider)
  514. container.innerHTML = `<my-provider><my-provider>`
  515. const provider = container.childNodes[0] as VueElement
  516. const consumer = provider.shadowRoot!.childNodes[0] as VueElement
  517. expect(consumer.shadowRoot!.innerHTML).toBe(`<div>injected!</div>`)
  518. foo.value = 'changed!'
  519. await nextTick()
  520. expect(consumer.shadowRoot!.innerHTML).toBe(`<div>changed!</div>`)
  521. })
  522. test('over slot composition', async () => {
  523. const foo = ref('injected!')
  524. const Provider = defineCustomElement({
  525. provide: {
  526. foo,
  527. },
  528. render() {
  529. return renderSlot(this.$slots, 'default')
  530. },
  531. })
  532. customElements.define('my-provider-2', Provider)
  533. container.innerHTML = `<my-provider-2><my-consumer></my-consumer><my-provider-2>`
  534. const provider = container.childNodes[0]
  535. const consumer = provider.childNodes[0] as VueElement
  536. expect(consumer.shadowRoot!.innerHTML).toBe(`<div>injected!</div>`)
  537. foo.value = 'changed!'
  538. await nextTick()
  539. expect(consumer.shadowRoot!.innerHTML).toBe(`<div>changed!</div>`)
  540. })
  541. test('inherited from ancestors', async () => {
  542. const fooA = ref('FooA!')
  543. const fooB = ref('FooB!')
  544. const ProviderA = defineCustomElement({
  545. provide: {
  546. fooA,
  547. },
  548. render() {
  549. return h('provider-b')
  550. },
  551. })
  552. const ProviderB = defineCustomElement({
  553. provide: {
  554. fooB,
  555. },
  556. render() {
  557. return h('my-multi-consumer')
  558. },
  559. })
  560. const Consumer = defineCustomElement({
  561. setup() {
  562. const fooA = inject<Ref>('fooA')!
  563. const fooB = inject<Ref>('fooB')!
  564. return () => h('div', `${fooA.value} ${fooB.value}`)
  565. },
  566. })
  567. customElements.define('provider-a', ProviderA)
  568. customElements.define('provider-b', ProviderB)
  569. customElements.define('my-multi-consumer', Consumer)
  570. container.innerHTML = `<provider-a><provider-a>`
  571. const providerA = container.childNodes[0] as VueElement
  572. const providerB = providerA.shadowRoot!.childNodes[0] as VueElement
  573. const consumer = providerB.shadowRoot!.childNodes[0] as VueElement
  574. expect(consumer.shadowRoot!.innerHTML).toBe(`<div>FooA! FooB!</div>`)
  575. fooA.value = 'changedA!'
  576. fooB.value = 'changedB!'
  577. await nextTick()
  578. expect(consumer.shadowRoot!.innerHTML).toBe(
  579. `<div>changedA! changedB!</div>`,
  580. )
  581. })
  582. })
  583. describe('styles', () => {
  584. function assertStyles(el: VueElement, css: string[]) {
  585. const styles = el.shadowRoot?.querySelectorAll('style')!
  586. expect(styles.length).toBe(css.length) // should not duplicate multiple copies from Bar
  587. for (let i = 0; i < css.length; i++) {
  588. expect(styles[i].textContent).toBe(css[i])
  589. }
  590. }
  591. test('should attach styles to shadow dom', async () => {
  592. const def = defineComponent({
  593. __hmrId: 'foo',
  594. styles: [`div { color: red; }`],
  595. render() {
  596. return h('div', 'hello')
  597. },
  598. })
  599. const Foo = defineCustomElement(def)
  600. customElements.define('my-el-with-styles', Foo)
  601. container.innerHTML = `<my-el-with-styles></my-el-with-styles>`
  602. const el = container.childNodes[0] as VueElement
  603. const style = el.shadowRoot?.querySelector('style')!
  604. expect(style.textContent).toBe(`div { color: red; }`)
  605. // hmr
  606. __VUE_HMR_RUNTIME__.reload('foo', {
  607. ...def,
  608. styles: [`div { color: blue; }`, `div { color: yellow; }`],
  609. } as any)
  610. await nextTick()
  611. assertStyles(el, [`div { color: blue; }`, `div { color: yellow; }`])
  612. })
  613. test("child components should inject styles to root element's shadow root", async () => {
  614. const Baz = () => h(Bar)
  615. const Bar = defineComponent({
  616. __hmrId: 'bar',
  617. styles: [`div { color: green; }`, `div { color: blue; }`],
  618. render() {
  619. return 'bar'
  620. },
  621. })
  622. const Foo = defineCustomElement({
  623. styles: [`div { color: red; }`],
  624. render() {
  625. return [h(Baz), h(Baz)]
  626. },
  627. })
  628. customElements.define('my-el-with-child-styles', Foo)
  629. container.innerHTML = `<my-el-with-child-styles></my-el-with-child-styles>`
  630. const el = container.childNodes[0] as VueElement
  631. // inject order should be child -> parent
  632. assertStyles(el, [
  633. `div { color: green; }`,
  634. `div { color: blue; }`,
  635. `div { color: red; }`,
  636. ])
  637. // hmr
  638. __VUE_HMR_RUNTIME__.reload(Bar.__hmrId!, {
  639. ...Bar,
  640. styles: [`div { color: red; }`, `div { color: yellow; }`],
  641. } as any)
  642. await nextTick()
  643. assertStyles(el, [
  644. `div { color: red; }`,
  645. `div { color: yellow; }`,
  646. `div { color: red; }`,
  647. ])
  648. __VUE_HMR_RUNTIME__.reload(Bar.__hmrId!, {
  649. ...Bar,
  650. styles: [`div { color: blue; }`],
  651. } as any)
  652. await nextTick()
  653. assertStyles(el, [`div { color: blue; }`, `div { color: red; }`])
  654. })
  655. test('with nonce', () => {
  656. const Foo = defineCustomElement(
  657. {
  658. styles: [`div { color: red; }`],
  659. render() {
  660. return h('div', 'hello')
  661. },
  662. },
  663. { nonce: 'xxx' },
  664. )
  665. customElements.define('my-el-with-nonce', Foo)
  666. container.innerHTML = `<my-el-with-nonce></my-el-with-nonce>`
  667. const el = container.childNodes[0] as VueElement
  668. const style = el.shadowRoot?.querySelector('style')!
  669. expect(style.getAttribute('nonce')).toBe('xxx')
  670. })
  671. })
  672. describe('async', () => {
  673. test('should work', async () => {
  674. const loaderSpy = vi.fn()
  675. const E = defineCustomElement(
  676. defineAsyncComponent(() => {
  677. loaderSpy()
  678. return Promise.resolve({
  679. props: ['msg'],
  680. styles: [`div { color: red }`],
  681. render(this: any) {
  682. return h('div', null, this.msg)
  683. },
  684. })
  685. }),
  686. )
  687. customElements.define('my-el-async', E)
  688. container.innerHTML =
  689. `<my-el-async msg="hello"></my-el-async>` +
  690. `<my-el-async msg="world"></my-el-async>`
  691. await new Promise(r => setTimeout(r))
  692. // loader should be called only once
  693. expect(loaderSpy).toHaveBeenCalledTimes(1)
  694. const e1 = container.childNodes[0] as VueElement
  695. const e2 = container.childNodes[1] as VueElement
  696. // should inject styles
  697. expect(e1.shadowRoot!.innerHTML).toBe(
  698. `<style>div { color: red }</style><div>hello</div>`,
  699. )
  700. expect(e2.shadowRoot!.innerHTML).toBe(
  701. `<style>div { color: red }</style><div>world</div>`,
  702. )
  703. // attr
  704. e1.setAttribute('msg', 'attr')
  705. await nextTick()
  706. expect((e1 as any).msg).toBe('attr')
  707. expect(e1.shadowRoot!.innerHTML).toBe(
  708. `<style>div { color: red }</style><div>attr</div>`,
  709. )
  710. // props
  711. expect(`msg` in e1).toBe(true)
  712. ;(e1 as any).msg = 'prop'
  713. expect(e1.getAttribute('msg')).toBe('prop')
  714. expect(e1.shadowRoot!.innerHTML).toBe(
  715. `<style>div { color: red }</style><div>prop</div>`,
  716. )
  717. })
  718. test('set DOM property before resolve', async () => {
  719. const E = defineCustomElement(
  720. defineAsyncComponent(() => {
  721. return Promise.resolve({
  722. props: ['msg'],
  723. setup(props) {
  724. expect(typeof props.msg).toBe('string')
  725. },
  726. render(this: any) {
  727. return h('div', this.msg)
  728. },
  729. })
  730. }),
  731. )
  732. customElements.define('my-el-async-2', E)
  733. const e1 = new E()
  734. // set property before connect
  735. e1.msg = 'hello'
  736. const e2 = new E()
  737. container.appendChild(e1)
  738. container.appendChild(e2)
  739. // set property after connect but before resolve
  740. e2.msg = 'world'
  741. await new Promise(r => setTimeout(r))
  742. expect(e1.shadowRoot!.innerHTML).toBe(`<div>hello</div>`)
  743. expect(e2.shadowRoot!.innerHTML).toBe(`<div>world</div>`)
  744. e1.msg = 'world'
  745. expect(e1.shadowRoot!.innerHTML).toBe(`<div>world</div>`)
  746. e2.msg = 'hello'
  747. expect(e2.shadowRoot!.innerHTML).toBe(`<div>hello</div>`)
  748. })
  749. test('Number prop casting before resolve', async () => {
  750. const E = defineCustomElement(
  751. defineAsyncComponent(() => {
  752. return Promise.resolve({
  753. props: { n: Number },
  754. setup(props) {
  755. expect(props.n).toBe(20)
  756. },
  757. render(this: any) {
  758. return h('div', this.n + ',' + typeof this.n)
  759. },
  760. })
  761. }),
  762. )
  763. customElements.define('my-el-async-3', E)
  764. container.innerHTML = `<my-el-async-3 n="2e1"></my-el-async-3>`
  765. await new Promise(r => setTimeout(r))
  766. const e = container.childNodes[0] as VueElement
  767. expect(e.shadowRoot!.innerHTML).toBe(`<div>20,number</div>`)
  768. })
  769. test('with slots', async () => {
  770. const E = defineCustomElement(
  771. defineAsyncComponent(() => {
  772. return Promise.resolve({
  773. render(this: any) {
  774. return [
  775. h('div', null, [
  776. renderSlot(this.$slots, 'default', undefined, () => [
  777. h('div', 'fallback'),
  778. ]),
  779. ]),
  780. h('div', null, renderSlot(this.$slots, 'named')),
  781. ]
  782. },
  783. })
  784. }),
  785. )
  786. customElements.define('my-el-async-slots', E)
  787. container.innerHTML = `<my-el-async-slots><span>hi</span></my-el-async-slots>`
  788. await new Promise(r => setTimeout(r))
  789. const e = container.childNodes[0] as VueElement
  790. expect(e.shadowRoot!.innerHTML).toBe(
  791. `<div><slot><div>fallback</div></slot></div><div><slot name="named"></slot></div>`,
  792. )
  793. })
  794. })
  795. describe('shadowRoot: false', () => {
  796. const E = defineCustomElement({
  797. shadowRoot: false,
  798. props: {
  799. msg: {
  800. type: String,
  801. default: 'hello',
  802. },
  803. },
  804. render() {
  805. return h('div', this.msg)
  806. },
  807. })
  808. customElements.define('my-el-shadowroot-false', E)
  809. test('should work', async () => {
  810. function raf() {
  811. return new Promise(resolve => {
  812. requestAnimationFrame(resolve)
  813. })
  814. }
  815. container.innerHTML = `<my-el-shadowroot-false></my-el-shadowroot-false>`
  816. const e = container.childNodes[0] as VueElement
  817. await raf()
  818. expect(e).toBeInstanceOf(E)
  819. expect(e._instance).toBeTruthy()
  820. expect(e.innerHTML).toBe(`<div>hello</div>`)
  821. expect(e.shadowRoot).toBe(null)
  822. })
  823. const toggle = ref(true)
  824. const ES = defineCustomElement(
  825. {
  826. render() {
  827. return [
  828. renderSlot(this.$slots, 'default'),
  829. toggle.value ? renderSlot(this.$slots, 'named') : null,
  830. renderSlot(this.$slots, 'omitted', {}, () => [
  831. h('div', 'fallback'),
  832. ]),
  833. ]
  834. },
  835. },
  836. { shadowRoot: false },
  837. )
  838. customElements.define('my-el-shadowroot-false-slots', ES)
  839. test('should render slots', async () => {
  840. container.innerHTML =
  841. `<my-el-shadowroot-false-slots>` +
  842. `<span>default</span>text` +
  843. `<div slot="named">named</div>` +
  844. `</my-el-shadowroot-false-slots>`
  845. const e = container.childNodes[0] as VueElement
  846. // native slots allocation does not affect innerHTML, so we just
  847. // verify that we've rendered the correct native slots here...
  848. expect(e.innerHTML).toBe(
  849. `<span>default</span>text` +
  850. `<div slot="named">named</div>` +
  851. `<div>fallback</div>`,
  852. )
  853. toggle.value = false
  854. await nextTick()
  855. expect(e.innerHTML).toBe(
  856. `<span>default</span>text` + `<!---->` + `<div>fallback</div>`,
  857. )
  858. })
  859. })
  860. describe('useCustomElementRoot', () => {
  861. test('should work for style injection', () => {
  862. const Foo = defineCustomElement({
  863. setup() {
  864. const root = useShadowRoot()!
  865. const style = document.createElement('style')
  866. style.innerHTML = `div { color: red; }`
  867. root.appendChild(style)
  868. return () => h('div', 'hello')
  869. },
  870. })
  871. customElements.define('my-el', Foo)
  872. container.innerHTML = `<my-el></my-el>`
  873. const el = container.childNodes[0] as VueElement
  874. const style = el.shadowRoot?.querySelector('style')!
  875. expect(style.textContent).toBe(`div { color: red; }`)
  876. })
  877. })
  878. describe('expose', () => {
  879. test('expose attributes and callback', async () => {
  880. type SetValue = (value: string) => void
  881. let fn: MockedFunction<SetValue>
  882. const E = defineCustomElement({
  883. setup(_, { expose }) {
  884. const value = ref('hello')
  885. const setValue = (fn = vi.fn((_value: string) => {
  886. value.value = _value
  887. }))
  888. expose({
  889. setValue,
  890. value,
  891. })
  892. return () => h('div', null, [value.value])
  893. },
  894. })
  895. customElements.define('my-el-expose', E)
  896. container.innerHTML = `<my-el-expose></my-el-expose>`
  897. const e = container.childNodes[0] as VueElement & {
  898. value: string
  899. setValue: MockedFunction<SetValue>
  900. }
  901. expect(e.shadowRoot!.innerHTML).toBe(`<div>hello</div>`)
  902. expect(e.value).toBe('hello')
  903. expect(e.setValue).toBe(fn!)
  904. e.setValue('world')
  905. expect(e.value).toBe('world')
  906. await nextTick()
  907. expect(e.shadowRoot!.innerHTML).toBe(`<div>world</div>`)
  908. })
  909. test('warning when exposing an existing property', () => {
  910. const E = defineCustomElement({
  911. props: {
  912. value: String,
  913. },
  914. setup(props, { expose }) {
  915. expose({
  916. value: 'hello',
  917. })
  918. return () => h('div', null, [props.value])
  919. },
  920. })
  921. customElements.define('my-el-expose-two', E)
  922. container.innerHTML = `<my-el-expose-two value="world"></my-el-expose-two>`
  923. expect(
  924. `[Vue warn]: Exposed property "value" already exists on custom element.`,
  925. ).toHaveBeenWarned()
  926. })
  927. })
  928. })