Skip to content

Commit bb47eab

Browse files
authored
Merge pull request #229 from github/add-createability
add createAbility
2 parents a399966 + 9c86f73 commit bb47eab

File tree

2 files changed

+360
-0
lines changed

2 files changed

+360
-0
lines changed

src/ability.ts

+82
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import type {CustomElement} from './custom-element.js'
2+
3+
export interface Ability extends CustomElement {
4+
[attachShadowCallback]?(shadowRoot: ShadowRoot): void
5+
[attachInternalsCallback]?(internals: ElementInternals): void
6+
}
7+
8+
export interface AbilityClass {
9+
new (): Ability
10+
observedAttributes?: string[]
11+
formAssociated?: boolean
12+
}
13+
14+
export const attachShadowCallback = Symbol()
15+
export const attachInternalsCallback = Symbol()
16+
17+
type Decorator = (Class: AbilityClass) => AbilityClass
18+
const abilityMarkers = new WeakMap<AbilityClass, Set<Decorator>>()
19+
export const createAbility = (decorate: Decorator) => {
20+
return (Class: AbilityClass): AbilityClass => {
21+
if (!abilityMarkers.has(Class)) Class = abilitable(Class)
22+
const markers = abilityMarkers.get(Class)
23+
if (markers?.has(decorate)) return Class
24+
const NewClass = decorate(Class as AbilityClass)
25+
const newMarkers = new Set(markers)
26+
newMarkers.add(decorate)
27+
abilityMarkers.set(NewClass, newMarkers)
28+
return NewClass
29+
}
30+
}
31+
32+
const shadows = new WeakMap<Ability, ShadowRoot | undefined>()
33+
const internals = new WeakMap<Ability, ElementInternals>()
34+
const internalsCalled = new WeakSet()
35+
const abilitable = (Class: AbilityClass): AbilityClass =>
36+
class extends Class {
37+
constructor() {
38+
super()
39+
const shadowRoot = this.shadowRoot
40+
if (shadowRoot && shadowRoot !== shadows.get(this)) this[attachShadowCallback](shadowRoot)
41+
if (!internalsCalled.has(this)) {
42+
try {
43+
this.attachInternals()
44+
} catch {
45+
// Ignore errors
46+
}
47+
}
48+
}
49+
50+
connectedCallback() {
51+
super.connectedCallback?.()
52+
this.setAttribute('data-catalyst', '')
53+
}
54+
55+
attachShadow(...args: [init: ShadowRootInit]): ShadowRoot {
56+
const shadowRoot = super.attachShadow(...args)
57+
this[attachShadowCallback](shadowRoot)
58+
return shadowRoot
59+
}
60+
61+
[attachShadowCallback](shadowRoot: ShadowRoot) {
62+
shadows.set(this, shadowRoot)
63+
}
64+
65+
attachInternals(): ElementInternals {
66+
if (internals.has(this) && !internalsCalled.has(this)) {
67+
internalsCalled.add(this)
68+
return internals.get(this)!
69+
}
70+
const elementInternals = super.attachInternals()
71+
this[attachInternalsCallback](elementInternals)
72+
internals.set(this, elementInternals)
73+
return elementInternals
74+
}
75+
76+
[attachInternalsCallback](elementInternals: ElementInternals) {
77+
const shadowRoot = elementInternals.shadowRoot
78+
if (shadowRoot && shadowRoot !== shadows.get(this)) {
79+
this[attachShadowCallback](shadowRoot)
80+
}
81+
}
82+
}

test/ability.ts

+278
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
import {expect, fixture, html} from '@open-wc/testing'
2+
import {restore, fake} from 'sinon'
3+
import {createAbility, attachShadowCallback, attachInternalsCallback} from '../src/ability.js'
4+
5+
describe('ability', () => {
6+
let calls = []
7+
const fakeable = createAbility(
8+
Class =>
9+
class extends Class {
10+
foo() {
11+
return 'foo!'
12+
}
13+
connectedCallback() {
14+
calls.push('fakeable connectedCallback')
15+
super.connectedCallback?.()
16+
}
17+
disconnectedCallback() {
18+
calls.push('fakeable disconnectedCallback')
19+
super.disconnectedCallback?.()
20+
}
21+
adoptedCallback() {
22+
calls.push('fakeable adoptedCallback')
23+
super.adoptedCallback?.()
24+
}
25+
attributeChangedCallback(...args) {
26+
calls.push('fakeable attributeChangedCallback')
27+
super.attributeChangedCallback?.(...args)
28+
}
29+
}
30+
)
31+
const otherfakeable = createAbility(
32+
Class =>
33+
class extends Class {
34+
bar() {
35+
return 'bar!'
36+
}
37+
connectedCallback() {
38+
calls.push('otherfakeable connectedCallback')
39+
super.connectedCallback?.()
40+
}
41+
disconnectedCallback() {
42+
calls.push('otherfakeable disconnectedCallback')
43+
super.disconnectedCallback?.()
44+
}
45+
adoptedCallback() {
46+
calls.push('otherfakeable adoptedCallback')
47+
super.adoptedCallback?.()
48+
}
49+
attributeChangedCallback(...args) {
50+
calls.push('otherfakeable attributeChangedCallback')
51+
super.attributeChangedCallback?.(...args)
52+
}
53+
}
54+
)
55+
class Element extends HTMLElement {
56+
connectedCallback() {}
57+
disconnectedCallback() {}
58+
adoptedCallback() {}
59+
attributeChangedCallback() {}
60+
}
61+
62+
afterEach(() => restore())
63+
64+
it('creates a function, which creates a subclass of the given class', async () => {
65+
const DElement = fakeable(Element)
66+
expect(DElement).to.have.property('prototype').instanceof(Element)
67+
})
68+
69+
it('can be used in decorator position', async () => {
70+
@fakeable
71+
class DElement extends HTMLElement {}
72+
73+
expect(DElement).to.have.property('prototype').instanceof(HTMLElement)
74+
})
75+
76+
it('can be chained with multiple abilities', async () => {
77+
const DElement = fakeable(Element)
78+
expect(Element).to.not.equal(DElement)
79+
const D2Element = otherfakeable(DElement)
80+
expect(DElement).to.not.equal(D2Element)
81+
expect(DElement).to.have.property('prototype').be.instanceof(Element)
82+
expect(D2Element).to.have.property('prototype').be.instanceof(Element)
83+
})
84+
85+
it('can be called multiple times, but only applies once', async () => {
86+
const MultipleFakeable = fakeable(fakeable(fakeable(fakeable(fakeable(Element)))))
87+
customElements.define('multiple-fakeable', MultipleFakeable)
88+
const instance = await fixture(html`<multiple-fakeable />`)
89+
expect(calls).to.eql(['fakeable connectedCallback'])
90+
instance.connectedCallback()
91+
expect(calls).to.eql(['fakeable connectedCallback', 'fakeable connectedCallback'])
92+
})
93+
94+
describe('subclass behaviour', () => {
95+
const CoreTest = otherfakeable(fakeable(Element))
96+
customElements.define('core-test', CoreTest)
97+
98+
let instance
99+
beforeEach(async () => {
100+
instance = await fixture(html`<core-test />`)
101+
})
102+
103+
it('applies keys from delegate onto subclass upon instantiation', () => {
104+
expect(instance).to.have.property('foo')
105+
expect(instance.foo()).to.equal('foo!')
106+
expect(instance).to.have.property('bar')
107+
expect(instance.bar()).to.equal('bar!')
108+
})
109+
110+
for (const method of ['connectedCallback', 'disconnectedCallback', 'adoptedCallback', 'attributeChangedCallback']) {
111+
it(`delegates to other ${method}s before class ${method}`, () => {
112+
calls = []
113+
instance[method]()
114+
expect(calls).to.eql([`otherfakeable ${method}`, `fakeable ${method}`])
115+
})
116+
}
117+
})
118+
119+
describe('ability extension behaviour', () => {
120+
describe('attachShadowCallback', () => {
121+
let attachShadowFake
122+
let shadow
123+
beforeEach(() => {
124+
shadow = null
125+
attachShadowFake = fake()
126+
})
127+
128+
const declarable = createAbility(
129+
Class =>
130+
class extends Class {
131+
[attachShadowCallback](...args) {
132+
super[attachShadowCallback](...args)
133+
return attachShadowFake.apply(this, args)
134+
}
135+
}
136+
)
137+
customElements.define(
138+
'declarative-shadow-ability',
139+
declarable(
140+
class extends HTMLElement {
141+
constructor() {
142+
super()
143+
// Declarative shadows run before constructor() is available, but
144+
// abilities run after element constructor
145+
shadow = HTMLElement.prototype.attachShadow.call(this, {mode: 'closed'})
146+
}
147+
}
148+
)
149+
)
150+
customElements.define(
151+
'closed-shadow-ability',
152+
declarable(
153+
class extends HTMLElement {
154+
constructor() {
155+
super()
156+
shadow = this.attachShadow({mode: 'closed'})
157+
}
158+
}
159+
)
160+
)
161+
customElements.define(
162+
'connected-shadow-ability',
163+
declarable(
164+
class extends HTMLElement {
165+
connectedCallback() {
166+
shadow = this.attachShadow({mode: 'closed'})
167+
}
168+
}
169+
)
170+
)
171+
customElements.define('manual-shadow-ability', declarable(class extends HTMLElement {}))
172+
173+
customElements.define(
174+
'disallowed-shadow-ability',
175+
declarable(
176+
class extends HTMLElement {
177+
static disabledFeatures = ['shadow']
178+
}
179+
)
180+
)
181+
182+
it('is called with shadowRoot of declarative ShadowDOM', async () => {
183+
const instance = await fixture(html`<declarative-shadow-ability></declarative-shadow-ability>`)
184+
expect(shadow).to.exist.and.be.instanceof(ShadowRoot)
185+
expect(attachShadowFake).to.be.calledOnce.calledOn(instance).and.calledWithExactly(shadow)
186+
})
187+
188+
it('is called with shadowRoot from attachShadow call', async () => {
189+
const instance = await fixture(html`<manual-shadow-ability></manual-shadow-ability>`)
190+
shadow = instance.attachShadow({mode: 'closed'})
191+
expect(shadow).to.exist.and.be.instanceof(ShadowRoot)
192+
expect(attachShadowFake).to.be.calledOnce.calledOn(instance).and.calledWithExactly(shadow)
193+
})
194+
195+
it('is called with shadowRoot from attachInternals call', async () => {
196+
const instance = await fixture(html`<closed-shadow-ability></closed-shadow-ability>`)
197+
expect(shadow).to.exist.and.be.instanceof(ShadowRoot)
198+
expect(attachShadowFake).to.be.calledOnce.calledOn(instance).and.calledWithExactly(shadow)
199+
})
200+
201+
it('is called with shadowRoot from connectedCallback', async () => {
202+
const instance = await fixture(html`<connected-shadow-ability></connected-shadow-ability>`)
203+
expect(shadow).to.exist.and.be.instanceof(ShadowRoot)
204+
expect(attachShadowFake).to.be.calledOnce.calledOn(instance).and.calledWithExactly(shadow)
205+
})
206+
207+
it('does not error if shadowdom is disabled', async () => {
208+
await fixture(html`<disabled-shadow-ability></disabled-shadow-ability>`)
209+
expect(attachShadowFake).to.be.have.callCount(0)
210+
})
211+
})
212+
213+
describe('attachInternalsCallback', () => {
214+
let attachInternalsFake
215+
let internals
216+
beforeEach(() => {
217+
internals = null
218+
attachInternalsFake = fake()
219+
})
220+
221+
const internable = createAbility(
222+
Class =>
223+
class extends Class {
224+
[attachInternalsCallback](...args) {
225+
super[attachInternalsCallback](...args)
226+
return attachInternalsFake.apply(this, args)
227+
}
228+
}
229+
)
230+
customElements.define(
231+
'internals-ability',
232+
internable(
233+
class extends HTMLElement {
234+
constructor() {
235+
super()
236+
internals = this.attachInternals()
237+
}
238+
}
239+
)
240+
)
241+
customElements.define('manual-internals-ability', internable(class extends HTMLElement {}))
242+
243+
customElements.define(
244+
'disallowed-internals-ability',
245+
internable(
246+
class extends HTMLElement {
247+
static disabledFeatures = ['internals']
248+
}
249+
)
250+
)
251+
252+
it('is called on constructor', async () => {
253+
const instance = await fixture(html`<manual-internals-ability></manual-internals-ability>`)
254+
expect(attachInternalsFake).to.be.calledOnce.calledOn(instance)
255+
})
256+
257+
it('does not prevent attachInternals being called by userland class', async () => {
258+
const instance = await fixture(html`<internals-ability></internals-ability>`)
259+
expect(internals).to.exist.and.be.instanceof(ElementInternals)
260+
expect(attachInternalsFake).to.be.calledOnce.calledOn(instance).and.calledWithExactly(internals)
261+
})
262+
263+
it('errors if userland calls attachInternals more than once', async () => {
264+
const instance = await fixture(html`<manual-internals-ability></manual-internals-ability>`)
265+
internals = instance.attachInternals()
266+
expect(internals).to.exist.and.be.instanceof(ElementInternals)
267+
expect(attachInternalsFake).to.be.calledOnce.calledOn(instance).and.calledWithExactly(internals)
268+
269+
expect(() => instance.attachInternals()).to.throw(DOMException)
270+
})
271+
272+
it('does not error if element internals are disabled', async () => {
273+
await fixture(html`<disallowed-internals-ability></disallowed-internals-ability>`)
274+
expect(attachInternalsFake).to.have.callCount(0)
275+
})
276+
})
277+
})
278+
})

0 commit comments

Comments
 (0)