Skip to content

Commit a6b678a

Browse files
committed
add providable implementation
1 parent 678aac0 commit a6b678a

2 files changed

Lines changed: 338 additions & 0 deletions

File tree

src/providable.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import type {CustomElementClass, CustomElement} from './custom-element.js'
2+
import {createMark} from './mark.js'
3+
import {createAbility} from './ability.js'
4+
5+
export interface Context<T> {
6+
name: PropertyKey
7+
initialValue?: T
8+
}
9+
export type ContextCallback<ValueType> = (value: ValueType, dispose?: () => void) => void
10+
export type ContextType<T extends Context<unknown>> = T extends Context<infer Y> ? Y : never
11+
12+
export class ContextEvent<T extends Context<unknown>> extends Event {
13+
public constructor(
14+
public readonly context: T,
15+
public readonly callback: ContextCallback<ContextType<T>>,
16+
public readonly multiple?: boolean
17+
) {
18+
super('context-request', {bubbles: true, composed: true})
19+
}
20+
}
21+
22+
function isContextEvent(event: unknown): event is ContextEvent<Context<unknown>> {
23+
return (
24+
event instanceof Event &&
25+
event.type === 'context-request' &&
26+
'context' in event &&
27+
'callback' in event &&
28+
'multiple' in event
29+
)
30+
}
31+
32+
const contexts = new WeakMap<CustomElement, Map<PropertyKey, Set<(value: unknown) => void>>>()
33+
const [provide, getProvide, initProvide] = createMark<CustomElement>(
34+
({name, kind}) => {
35+
if (kind === 'setter') throw new Error(`@provide cannot decorate setter ${String(name)}`)
36+
if (kind === 'method') throw new Error(`@provide cannot decorate method ${String(name)}`)
37+
},
38+
(instance: CustomElement, {name, kind, access}) => {
39+
return {
40+
get: () => (kind === 'getter' ? access.get!.call(instance) : access.value),
41+
set: (newValue: unknown) => {
42+
access.set?.call(instance, newValue)
43+
for (const callback of contexts.get(instance)?.get(name) || []) callback(newValue)
44+
}
45+
}
46+
}
47+
)
48+
const [consume, getConsume, initConsume] = createMark<CustomElement>(
49+
({name, kind}) => {
50+
if (kind === 'method') throw new Error(`@consume cannot decorate method ${String(name)}`)
51+
},
52+
(instance: CustomElement, {name, access}) => {
53+
const initialValue: unknown = access.get?.call(instance) ?? access.value
54+
let currentValue = initialValue
55+
instance.dispatchEvent(
56+
new ContextEvent(
57+
{name, initialValue},
58+
(value: unknown, dispose?: () => void) => {
59+
if (!disposes.has(instance)) disposes.set(instance, new Map())
60+
const instanceDisposes = disposes.get(instance)!
61+
if (instanceDisposes.has(name)) instanceDisposes.get(name)!()
62+
if (dispose) instanceDisposes.set(name, dispose)
63+
currentValue = value
64+
access.set?.call(instance, currentValue)
65+
},
66+
true
67+
)
68+
)
69+
return {get: () => currentValue}
70+
}
71+
)
72+
73+
const disposes = new WeakMap<CustomElement, Map<PropertyKey, () => void>>()
74+
75+
export {consume, provide, getProvide, getConsume}
76+
export const providable = createAbility(
77+
<T extends CustomElementClass>(Class: T): T =>
78+
class extends Class {
79+
[key: PropertyKey]: unknown
80+
81+
// TS mandates Constructors that get mixins have `...args: any[]`
82+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
83+
constructor(...args: any[]) {
84+
super(...args)
85+
initProvide(this)
86+
if (getProvide(this).size) {
87+
this.addEventListener('context-request', event => {
88+
if (!isContextEvent(event)) return
89+
const name = event.context.name
90+
const value = this[name]
91+
const callback = event.callback
92+
if (event.multiple) {
93+
if (!contexts.has(this)) contexts.set(this, new Map())
94+
const instanceContexts = contexts.get(this)!
95+
if (!instanceContexts.has(name)) instanceContexts.set(name, new Set())
96+
instanceContexts.get(name)!.add(callback)
97+
}
98+
callback(value, () => contexts.get(this)?.get(name)?.delete(callback))
99+
})
100+
}
101+
}
102+
103+
connectedCallback() {
104+
initConsume(this)
105+
super.connectedCallback?.()
106+
}
107+
108+
disconnectedCallback() {
109+
for (const dispose of disposes.get(this)?.values() || []) {
110+
dispose()
111+
}
112+
}
113+
}
114+
)

test/providable.ts

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
import {expect, fixture, html} from '@open-wc/testing'
2+
import {fake} from 'sinon'
3+
import {provide, consume, providable, ContextEvent} from '../src/providable.js'
4+
5+
describe('Providable', () => {
6+
const sym = Symbol('bing')
7+
@providable
8+
class ProvidableProviderTest extends HTMLElement {
9+
@provide foo = 'hello'
10+
@provide bar = 'world'
11+
@provide get baz() {
12+
return 3
13+
}
14+
@provide [sym] = {provided: true}
15+
@provide qux = 8
16+
}
17+
window.customElements.define('providable-provider-test', ProvidableProviderTest)
18+
19+
@providable
20+
class ProvidableConsumerTest extends HTMLElement {
21+
@consume foo = 'goodbye'
22+
@consume bar = 'universe'
23+
@consume get baz() {
24+
return 1
25+
}
26+
@consume [sym] = {}
27+
count = 0
28+
get qux() {
29+
return this.count
30+
}
31+
@consume set qux(value: number) {
32+
this.count += 1
33+
}
34+
connectedCallback() {
35+
this.textContent = `${this.foo} ${this.bar}`
36+
}
37+
}
38+
window.customElements.define('providable-consumer-test', ProvidableConsumerTest)
39+
40+
describe('consumer without provider', () => {
41+
let instance: ProvidableConsumerTest
42+
beforeEach(async () => {
43+
instance = await fixture(html`<providable-consumer-test />`)
44+
})
45+
46+
it('uses the given values', () => {
47+
expect(instance).to.have.property('foo', 'goodbye')
48+
expect(instance).to.have.property('bar', 'universe')
49+
expect(instance).to.have.property('baz', 1)
50+
expect(instance).to.have.property(sym).eql({})
51+
expect(instance).to.have.property('textContent', 'goodbye universe')
52+
})
53+
54+
it('overrides the property definitions to not be setters', () => {
55+
expect(() => (instance.foo = 'hello')).to.throw()
56+
expect(() => (instance.bar = 'world')).to.throw()
57+
// @ts-expect-error this was only a getter to begin with
58+
expect(() => (instance.baz = 3)).to.throw()
59+
})
60+
61+
it('emits the `context-request` event when connected, for each field', async () => {
62+
instance = document.createElement('providable-consumer-test') as ProvidableConsumerTest
63+
const events = fake()
64+
instance.addEventListener('context-request', events)
65+
await fixture(instance)
66+
67+
expect(events).to.have.callCount(5)
68+
const fooEvent = events.getCall(0).args[0]
69+
expect(fooEvent).to.be.instanceof(ContextEvent)
70+
expect(fooEvent).to.have.nested.property('context.name', 'foo')
71+
expect(fooEvent).to.have.nested.property('context.initialValue', 'goodbye')
72+
expect(fooEvent).to.have.property('multiple', true)
73+
expect(fooEvent).to.have.property('bubbles', true)
74+
75+
const barEvent = events.getCall(1).args[0]
76+
expect(barEvent).to.be.instanceof(ContextEvent)
77+
expect(barEvent).to.have.nested.property('context.name', 'bar')
78+
expect(barEvent).to.have.nested.property('context.initialValue', 'universe')
79+
expect(barEvent).to.have.property('multiple', true)
80+
expect(barEvent).to.have.property('bubbles', true)
81+
82+
const bazEvent = events.getCall(2).args[0]
83+
expect(bazEvent).to.be.instanceof(ContextEvent)
84+
expect(bazEvent).to.have.nested.property('context.name', 'baz')
85+
expect(bazEvent).to.have.nested.property('context.initialValue', 1)
86+
expect(bazEvent).to.have.property('multiple', true)
87+
expect(bazEvent).to.have.property('bubbles', true)
88+
89+
const bingEvent = events.getCall(3).args[0]
90+
expect(bingEvent).to.be.instanceof(ContextEvent)
91+
expect(bingEvent).to.have.nested.property('context.name', sym)
92+
expect(bingEvent).to.have.nested.property('context.initialValue').eql({})
93+
expect(bingEvent).to.have.property('multiple', true)
94+
expect(bingEvent).to.have.property('bubbles', true)
95+
96+
const quxEvent = events.getCall(4).args[0]
97+
expect(quxEvent).to.be.instanceof(ContextEvent)
98+
expect(quxEvent).to.have.nested.property('context.name', 'qux')
99+
expect(quxEvent).to.have.nested.property('context.initialValue').eql(0)
100+
expect(quxEvent).to.have.property('multiple', true)
101+
expect(quxEvent).to.have.property('bubbles', true)
102+
})
103+
})
104+
105+
describe('provider', () => {
106+
let provider: ProvidableProviderTest
107+
beforeEach(async () => {
108+
provider = await fixture(
109+
html`<providable-provider-test
110+
><div>
111+
<span><strong></strong></span></div
112+
></providable-provider-test>`
113+
)
114+
})
115+
116+
it('listens for `context-request` events, calling back with values', () => {
117+
const fooCallback = fake()
118+
provider.dispatchEvent(new ContextEvent({name: 'foo', initialValue: 'a'}, fooCallback, true))
119+
expect(fooCallback).to.have.callCount(1).and.be.calledWith('hello')
120+
const barCallback = fake()
121+
provider.querySelector('strong')!.dispatchEvent(new ContextEvent({name: 'bar', initialValue: 'a'}, barCallback))
122+
expect(barCallback).to.have.callCount(1).and.be.calledWith('world')
123+
})
124+
125+
it('re-calls callback each time value changes', () => {
126+
const fooCallback = fake()
127+
provider.dispatchEvent(new ContextEvent({name: 'foo', initialValue: 'a'}, fooCallback, true))
128+
expect(fooCallback).to.have.callCount(1).and.be.calledWith('hello')
129+
provider.foo = 'goodbye'
130+
expect(fooCallback).to.have.callCount(2).and.be.calledWith('goodbye')
131+
provider.foo = 'greetings'
132+
expect(fooCallback).to.have.callCount(3).and.be.calledWith('greetings')
133+
})
134+
135+
it('does not re-call callback if `multiple` is `false`', () => {
136+
const fooCallback = fake()
137+
provider.dispatchEvent(new ContextEvent({name: 'foo', initialValue: 'a'}, fooCallback, false))
138+
expect(fooCallback).to.have.callCount(1).and.be.calledWith('hello')
139+
provider.foo = 'goodbye'
140+
expect(fooCallback).to.have.callCount(1)
141+
})
142+
143+
it('does not re-call callback once `dispose` has been called', () => {
144+
const fooCallback = fake()
145+
provider.dispatchEvent(new ContextEvent({name: 'foo', initialValue: 'a'}, fooCallback, true))
146+
expect(fooCallback).to.have.callCount(1).and.be.calledWith('hello')
147+
const dispose = fooCallback.getCall(0).args[1]
148+
dispose()
149+
provider.foo = 'goodbye'
150+
expect(fooCallback).to.have.callCount(1)
151+
})
152+
})
153+
154+
describe('consumer with provider parent', () => {
155+
let provider: ProvidableProviderTest
156+
let consumer: ProvidableConsumerTest
157+
beforeEach(async () => {
158+
provider = await fixture(html`<providable-provider-test>
159+
<main>
160+
<article>
161+
<section>
162+
<div>
163+
<providable-consumer-test></providable-consumer-test>
164+
</div>
165+
</section>
166+
</article>
167+
</main>
168+
</providable-provider-test>`)
169+
consumer = provider.querySelector<ProvidableConsumerTest>('providable-consumer-test')!
170+
})
171+
172+
it('uses values provided by provider', () => {
173+
expect(consumer).to.have.property('foo', 'hello')
174+
expect(consumer).to.have.property('bar', 'world')
175+
expect(consumer).to.have.property('baz', 3)
176+
expect(consumer).to.have.property(sym).eql({provided: true})
177+
expect(consumer).to.have.property('qux').eql(8)
178+
})
179+
180+
it('updates values provided if they change', () => {
181+
expect(provider).to.have.property('foo', 'hello')
182+
expect(consumer).to.have.property('foo', 'hello')
183+
provider.foo = 'greetings'
184+
expect(consumer).to.have.property('foo', 'greetings')
185+
})
186+
187+
it('calls consumer set callbacks when the value is updated', () => {
188+
expect(consumer).to.have.property('qux', 8)
189+
expect(consumer).to.have.property('count', 1)
190+
provider.qux = 17
191+
expect(consumer).to.have.property('qux', 17)
192+
expect(consumer).to.have.property('count', 2)
193+
})
194+
})
195+
196+
describe('error scenarios', () => {
197+
it('cannot decorate methods as providers', () => {
198+
expect(() => {
199+
class Foo {
200+
@provide foo() {}
201+
}
202+
new Foo()
203+
}).to.throw(/provide cannot decorate method/)
204+
})
205+
206+
it('cannot decorate setters as providers', () => {
207+
expect(() => {
208+
class Foo {
209+
@provide set foo(v: string) {}
210+
}
211+
new Foo()
212+
}).to.throw(/provide cannot decorate setter/)
213+
})
214+
215+
it('cannot decorate methods as consumers', () => {
216+
expect(() => {
217+
class Foo {
218+
@consume foo() {}
219+
}
220+
new Foo()
221+
}).to.throw(/consume cannot decorate method/)
222+
})
223+
})
224+
})

0 commit comments

Comments
 (0)