Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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