Skip to content

Commit

Permalink
Merge pull request #278 from github/add-provideasync
Browse files Browse the repository at this point in the history
add provideAsync
  • Loading branch information
keithamus authored Sep 2, 2022
2 parents 5a4cf07 + 0e5c8bf commit 56caca9
Show file tree
Hide file tree
Showing 5 changed files with 106 additions and 14 deletions.
40 changes: 39 additions & 1 deletion docs/_guide/providable.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ The [Provider pattern](https://www.patterns.dev/posts/provider-pattern/) allows

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).

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`:
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`:

```typescript
import {providable, consume, provide, controller} from '@github/catalyst'
Expand Down Expand Up @@ -60,6 +60,8 @@ class UserRow extends HTMLElement {
</user-row>
```

### Combining Providables with Attributes

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`:

```typescript
Expand All @@ -83,6 +85,8 @@ class UserRow extends HTMLElement {
</user-row>
```

### Providing advanced values

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`:


Expand Down Expand Up @@ -142,4 +146,38 @@ class FollowUser extends HTMLElement {
</user-list>
```

### Asynchronous Providers

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!

```ts
import {providable, consume, provideAsync, target, attr, controller} from '@github/catalyst'

@controller
@providable
class ServerState extends HTMLElement {
@provideAsync get hitCount(): Promise<number> {
return (async () => {
const res = await fetch('/hitcount')
const json = await res.json()
return json.hits
})()
}
}

@controller
class HitCount extends HTMLElement {
@consume set hitCount(count: number) {
this.innerHTML = html`${count} hits!`
}
}
```
```html
<server-state>
<hit-count>
Loading...
</hit-count>
</server-state>
```

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).
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@
{
"path": "lib/abilities.js",
"import": "{providable}",
"limit": "1.1kb"
"limit": "1.5kb"
}
]
}
28 changes: 24 additions & 4 deletions src/providable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,21 @@ const [provide, getProvide, initProvide] = createMark<CustomElement>(
}
}
)
const [provideAsync, getProvideAsync, initProvideAsync] = createMark<CustomElement>(
({name, kind}) => {
if (kind === 'setter') throw new Error(`@provide cannot decorate setter ${String(name)}`)
if (kind === 'method') throw new Error(`@provide cannot decorate method ${String(name)}`)
},
(instance: CustomElement, {name, kind, access}) => {
return {
get: () => (kind === 'getter' ? access.get!.call(instance) : access.value),
set: (newValue: unknown) => {
access.set?.call(instance, newValue)
for (const callback of contexts.get(instance)?.get(name) || []) callback(newValue)
}
}
}
)
const [consume, getConsume, initConsume] = createMark<CustomElement>(
({name, kind}) => {
if (kind === 'method') throw new Error(`@consume cannot decorate method ${String(name)}`)
Expand Down Expand Up @@ -75,7 +90,7 @@ const [consume, getConsume, initConsume] = createMark<CustomElement>(

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

export {consume, provide, getProvide, getConsume}
export {consume, provide, provideAsync, getProvide, getProvideAsync, getConsume}
export const providable = createAbility(
<T extends CustomElementClass>(Class: T): T =>
class extends Class {
Expand All @@ -86,18 +101,23 @@ export const providable = createAbility(
constructor(...args: any[]) {
super(...args)
initProvide(this)
initProvideAsync(this)
const provides = getProvide(this)
if (provides.size) {
const providesAsync = getProvideAsync(this)
if (provides.size || providesAsync.size) {
if (!contexts.has(this)) contexts.set(this, new Map())
const instanceContexts = contexts.get(this)!
this.addEventListener('context-request', event => {
if (!isContextEvent(event)) return
const name = event.context.name
if (!provides.has(name)) return
if (!provides.has(name) && !providesAsync.has(name)) return
const value = this[name]
const dispose = () => instanceContexts.get(name)?.delete(callback)
const eventCallback = event.callback
const callback = (newValue: unknown) => eventCallback(newValue, dispose)
let callback = (newValue: unknown) => eventCallback(newValue, dispose)
if (providesAsync.has(name)) {
callback = async (newValue: unknown) => eventCallback(await newValue, dispose)
}
if (event.multiple) {
if (!instanceContexts.has(name)) instanceContexts.set(name, new Set())
instanceContexts.get(name)!.add(callback)
Expand Down
16 changes: 9 additions & 7 deletions test/lazy-define.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,16 @@ import {expect, fixture, html} from '@open-wc/testing'
import {spy} from 'sinon'
import {lazyDefine} from '../src/lazy-define.js'

const animationFrame = () => new Promise<unknown>(resolve => requestAnimationFrame(resolve))

describe('lazyDefine', () => {
describe('ready strategy', () => {
it('calls define for a lazy component', async () => {
const onDefine = spy()
lazyDefine('scan-document-test', onDefine)
await fixture(html`<scan-document-test></scan-document-test>`)

await new Promise<unknown>(resolve => requestAnimationFrame(resolve))
await animationFrame()

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

await new Promise<unknown>(resolve => requestAnimationFrame(resolve))
await animationFrame()

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

await new Promise<unknown>(resolve => requestAnimationFrame(resolve))
await animationFrame()

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

await new Promise<unknown>(resolve => requestAnimationFrame(resolve))
await animationFrame()
expect(onDefine).to.be.callCount(0)

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

await new Promise<unknown>(resolve => requestAnimationFrame(resolve))
await animationFrame()
expect(onDefine).to.be.callCount(1)
})
})
Expand All @@ -68,12 +70,12 @@ describe('lazyDefine', () => {
html`<div style="height: calc(100vh + 256px)"></div>
<scan-document-test data-load-on="visible"></scan-document-test>`
)
await new Promise<unknown>(resolve => requestAnimationFrame(resolve))
await animationFrame()
expect(onDefine).to.be.callCount(0)

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

await new Promise<unknown>(resolve => requestAnimationFrame(resolve))
await animationFrame()
expect(onDefine).to.be.callCount(1)
})
})
Expand Down
34 changes: 33 additions & 1 deletion test/providable.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {expect, fixture, html} from '@open-wc/testing'
import {fake} from 'sinon'
import {provide, consume, providable, ContextEvent} from '../src/providable.js'
import {provide, provideAsync, consume, providable, ContextEvent} from '../src/providable.js'

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

@providable
class AsyncProvidableProviderTest extends HTMLElement {
@provideAsync foo = Promise.resolve('hello')
@provideAsync bar = Promise.resolve('world')
@provideAsync get baz() {
return Promise.resolve(3)
}
@provideAsync [sym] = Promise.resolve({provided: true})
@provideAsync qux = Promise.resolve(8)
}
window.customElements.define('async-providable-provider-test', AsyncProvidableProviderTest)

@providable
class ProvidableSomeProviderTest extends HTMLElement {
@provide foo = 'greetings'
Expand Down Expand Up @@ -277,6 +289,26 @@ describe('Providable', () => {
})
})

describe('async provider', () => {
let provider: AsyncProvidableProviderTest
let consumer: ProvidableConsumerTest
beforeEach(async () => {
provider = await fixture(html`<async-providable-provider-test>
<providable-consumer-test></providable-consumer-test>
</async-providable-provider-test>`)
consumer = provider.querySelector<ProvidableConsumerTest>('providable-consumer-test')!
})

it('passes resovled values to consumer', async () => {
expect(consumer).to.have.property('foo', 'hello')
expect(consumer).to.have.property('bar', 'world')
expect(consumer).to.have.property('baz', 3)
expect(consumer).to.have.property(sym).eql({provided: true})
expect(consumer).to.have.property('qux').eql(8)
expect(consumer).to.have.property('count').eql(1)
})
})

describe('error scenarios', () => {
it('cannot decorate methods as providers', () => {
expect(() => {
Expand Down

0 comments on commit 56caca9

Please sign in to comment.