Skip to content

Commit 56caca9

Browse files
authored
Merge pull request #278 from github/add-provideasync
add provideAsync
2 parents 5a4cf07 + 0e5c8bf commit 56caca9

5 files changed

Lines changed: 106 additions & 14 deletions

File tree

docs/_guide/providable.md

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ The [Provider pattern](https://www.patterns.dev/posts/provider-pattern/) allows
88

99
Say for example a set of your components are built to perform actions on a user, but need a User ID. One way to handle this is to set the User ID as an attribute on each element, but this can lead to a lot of duplication. Instead these actions can request the ID from a parent component, which can provide the User ID without creating an explicit relationship (which can lead to brittle code).
1010

11-
The `@providable` ability allows a Catalyst controller to become a provider or consumer (or both) of one or many properties. To provide a property to nested controllers that ask for it, mark a property as `@provide`. To consume a property from a parent, mark a property as `@consume`. Let's try implementing the user actions using `@providable`:
11+
The `@providable` ability allows a Catalyst controller to become a provider or consumer (or both) of one or many properties. To provide a property to nested controllers that ask for it, mark a property as `@provide` or `@provideAsync`. To consume a property from a parent, mark a property as `@consume`. Let's try implementing the user actions using `@providable`:
1212

1313
```typescript
1414
import {providable, consume, provide, controller} from '@github/catalyst'
@@ -60,6 +60,8 @@ class UserRow extends HTMLElement {
6060
</user-row>
6161
```
6262

63+
### Combining Providables with Attributes
64+
6365
This shows how the basic pattern works, but `UserRow` having fixed strings isn't very useful. The `@provide` decorator can be combined with other decorators to make it more powerful, for example `@attr`:
6466

6567
```typescript
@@ -83,6 +85,8 @@ class UserRow extends HTMLElement {
8385
</user-row>
8486
```
8587

88+
### Providing advanced values
89+
8690
Values aren't just limited to strings, they can be any type; for example functions, classes, or even other controllers! We could implement a custom dialog component which exists as a sibling and invoke it using providers and `@target`:
8791

8892

@@ -142,4 +146,38 @@ class FollowUser extends HTMLElement {
142146
</user-list>
143147
```
144148

149+
### Asynchronous Providers
150+
151+
Sometimes you might want to have a provider do some asynchronous work - such as fetch some data over the network, and only provide the fully resolved value. In this case you can use the `@provideAsync` decorator. This decorator resolves the value before giving it to the consumer, so the consumer never deals with the Promise!
152+
153+
```ts
154+
import {providable, consume, provideAsync, target, attr, controller} from '@github/catalyst'
155+
156+
@controller
157+
@providable
158+
class ServerState extends HTMLElement {
159+
@provideAsync get hitCount(): Promise<number> {
160+
return (async () => {
161+
const res = await fetch('/hitcount')
162+
const json = await res.json()
163+
return json.hits
164+
})()
165+
}
166+
}
167+
168+
@controller
169+
class HitCount extends HTMLElement {
170+
@consume set hitCount(count: number) {
171+
this.innerHTML = html`${count} hits!`
172+
}
173+
}
174+
```
175+
```html
176+
<server-state>
177+
<hit-count>
178+
Loading...
179+
</hit-count>
180+
</server-state>
181+
```
182+
145183
If you're interested to find out how the Provider pattern works, you can look at the [context community-protocol as part of webcomponents-cg](https://github.com/webcomponents-cg/community-protocols/blob/main/proposals/context.md).

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@
5959
{
6060
"path": "lib/abilities.js",
6161
"import": "{providable}",
62-
"limit": "1.1kb"
62+
"limit": "1.5kb"
6363
}
6464
]
6565
}

src/providable.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,21 @@ const [provide, getProvide, initProvide] = createMark<CustomElement>(
4545
}
4646
}
4747
)
48+
const [provideAsync, getProvideAsync, initProvideAsync] = createMark<CustomElement>(
49+
({name, kind}) => {
50+
if (kind === 'setter') throw new Error(`@provide cannot decorate setter ${String(name)}`)
51+
if (kind === 'method') throw new Error(`@provide cannot decorate method ${String(name)}`)
52+
},
53+
(instance: CustomElement, {name, kind, access}) => {
54+
return {
55+
get: () => (kind === 'getter' ? access.get!.call(instance) : access.value),
56+
set: (newValue: unknown) => {
57+
access.set?.call(instance, newValue)
58+
for (const callback of contexts.get(instance)?.get(name) || []) callback(newValue)
59+
}
60+
}
61+
}
62+
)
4863
const [consume, getConsume, initConsume] = createMark<CustomElement>(
4964
({name, kind}) => {
5065
if (kind === 'method') throw new Error(`@consume cannot decorate method ${String(name)}`)
@@ -75,7 +90,7 @@ const [consume, getConsume, initConsume] = createMark<CustomElement>(
7590

7691
const disposes = new WeakMap<CustomElement, Map<PropertyKey, () => void>>()
7792

78-
export {consume, provide, getProvide, getConsume}
93+
export {consume, provide, provideAsync, getProvide, getProvideAsync, getConsume}
7994
export const providable = createAbility(
8095
<T extends CustomElementClass>(Class: T): T =>
8196
class extends Class {
@@ -86,18 +101,23 @@ export const providable = createAbility(
86101
constructor(...args: any[]) {
87102
super(...args)
88103
initProvide(this)
104+
initProvideAsync(this)
89105
const provides = getProvide(this)
90-
if (provides.size) {
106+
const providesAsync = getProvideAsync(this)
107+
if (provides.size || providesAsync.size) {
91108
if (!contexts.has(this)) contexts.set(this, new Map())
92109
const instanceContexts = contexts.get(this)!
93110
this.addEventListener('context-request', event => {
94111
if (!isContextEvent(event)) return
95112
const name = event.context.name
96-
if (!provides.has(name)) return
113+
if (!provides.has(name) && !providesAsync.has(name)) return
97114
const value = this[name]
98115
const dispose = () => instanceContexts.get(name)?.delete(callback)
99116
const eventCallback = event.callback
100-
const callback = (newValue: unknown) => eventCallback(newValue, dispose)
117+
let callback = (newValue: unknown) => eventCallback(newValue, dispose)
118+
if (providesAsync.has(name)) {
119+
callback = async (newValue: unknown) => eventCallback(await newValue, dispose)
120+
}
101121
if (event.multiple) {
102122
if (!instanceContexts.has(name)) instanceContexts.set(name, new Set())
103123
instanceContexts.get(name)!.add(callback)

test/lazy-define.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,16 @@ import {expect, fixture, html} from '@open-wc/testing'
22
import {spy} from 'sinon'
33
import {lazyDefine} from '../src/lazy-define.js'
44

5+
const animationFrame = () => new Promise<unknown>(resolve => requestAnimationFrame(resolve))
6+
57
describe('lazyDefine', () => {
68
describe('ready strategy', () => {
79
it('calls define for a lazy component', async () => {
810
const onDefine = spy()
911
lazyDefine('scan-document-test', onDefine)
1012
await fixture(html`<scan-document-test></scan-document-test>`)
1113

12-
await new Promise<unknown>(resolve => requestAnimationFrame(resolve))
14+
await animationFrame()
1315

1416
expect(onDefine).to.be.callCount(1)
1517
})
@@ -19,7 +21,7 @@ describe('lazyDefine', () => {
1921
await fixture(html`<later-defined-element-test></later-defined-element-test>`)
2022
lazyDefine('later-defined-element-test', onDefine)
2123

22-
await new Promise<unknown>(resolve => requestAnimationFrame(resolve))
24+
await animationFrame()
2325

2426
expect(onDefine).to.be.callCount(1)
2527
})
@@ -39,7 +41,7 @@ describe('lazyDefine', () => {
3941
<twice-defined-element></twice-defined-element>
4042
`)
4143

42-
await new Promise<unknown>(resolve => requestAnimationFrame(resolve))
44+
await animationFrame()
4345

4446
expect(onDefine).to.be.callCount(2)
4547
})
@@ -51,12 +53,12 @@ describe('lazyDefine', () => {
5153
lazyDefine('scan-document-test', onDefine)
5254
await fixture(html`<scan-document-test data-load-on="firstInteraction"></scan-document-test>`)
5355

54-
await new Promise<unknown>(resolve => requestAnimationFrame(resolve))
56+
await animationFrame()
5557
expect(onDefine).to.be.callCount(0)
5658

5759
document.dispatchEvent(new Event('mousedown'))
5860

59-
await new Promise<unknown>(resolve => requestAnimationFrame(resolve))
61+
await animationFrame()
6062
expect(onDefine).to.be.callCount(1)
6163
})
6264
})
@@ -68,12 +70,12 @@ describe('lazyDefine', () => {
6870
html`<div style="height: calc(100vh + 256px)"></div>
6971
<scan-document-test data-load-on="visible"></scan-document-test>`
7072
)
71-
await new Promise<unknown>(resolve => requestAnimationFrame(resolve))
73+
await animationFrame()
7274
expect(onDefine).to.be.callCount(0)
7375

7476
document.documentElement.scrollTo({top: 10})
7577

76-
await new Promise<unknown>(resolve => requestAnimationFrame(resolve))
78+
await animationFrame()
7779
expect(onDefine).to.be.callCount(1)
7880
})
7981
})

test/providable.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {expect, fixture, html} from '@open-wc/testing'
22
import {fake} from 'sinon'
3-
import {provide, consume, providable, ContextEvent} from '../src/providable.js'
3+
import {provide, provideAsync, consume, providable, ContextEvent} from '../src/providable.js'
44

55
describe('Providable', () => {
66
const sym = Symbol('bing')
@@ -16,6 +16,18 @@ describe('Providable', () => {
1616
}
1717
window.customElements.define('providable-provider-test', ProvidableProviderTest)
1818

19+
@providable
20+
class AsyncProvidableProviderTest extends HTMLElement {
21+
@provideAsync foo = Promise.resolve('hello')
22+
@provideAsync bar = Promise.resolve('world')
23+
@provideAsync get baz() {
24+
return Promise.resolve(3)
25+
}
26+
@provideAsync [sym] = Promise.resolve({provided: true})
27+
@provideAsync qux = Promise.resolve(8)
28+
}
29+
window.customElements.define('async-providable-provider-test', AsyncProvidableProviderTest)
30+
1931
@providable
2032
class ProvidableSomeProviderTest extends HTMLElement {
2133
@provide foo = 'greetings'
@@ -277,6 +289,26 @@ describe('Providable', () => {
277289
})
278290
})
279291

292+
describe('async provider', () => {
293+
let provider: AsyncProvidableProviderTest
294+
let consumer: ProvidableConsumerTest
295+
beforeEach(async () => {
296+
provider = await fixture(html`<async-providable-provider-test>
297+
<providable-consumer-test></providable-consumer-test>
298+
</async-providable-provider-test>`)
299+
consumer = provider.querySelector<ProvidableConsumerTest>('providable-consumer-test')!
300+
})
301+
302+
it('passes resovled values to consumer', async () => {
303+
expect(consumer).to.have.property('foo', 'hello')
304+
expect(consumer).to.have.property('bar', 'world')
305+
expect(consumer).to.have.property('baz', 3)
306+
expect(consumer).to.have.property(sym).eql({provided: true})
307+
expect(consumer).to.have.property('qux').eql(8)
308+
expect(consumer).to.have.property('count').eql(1)
309+
})
310+
})
311+
280312
describe('error scenarios', () => {
281313
it('cannot decorate methods as providers', () => {
282314
expect(() => {

0 commit comments

Comments
 (0)