├── .github └── workflows │ ├── node.js.yml │ └── npm-publish.yml ├── .gitignore ├── README.md ├── context-protocol.ts ├── index.ts ├── observable-map.ts ├── package-lock.json ├── package.json ├── test ├── async.test.html ├── fixture │ ├── html │ │ ├── consumer-element.ts │ │ └── provider-element.ts │ └── lit │ │ ├── consumer-element.ts │ │ └── provider-element.ts ├── mixins │ ├── parent-html-child-html │ │ ├── dedupe-mixin.test.ts │ │ └── scoped-elements-mixin.test.ts │ ├── parent-html-child-lit │ │ ├── dedupe-mixin.test.ts │ │ └── scoped-elements-mixin.test.ts │ ├── parent-lit-child-html │ │ ├── dedupe-mixin.test.ts │ │ └── scoped-elements-mixin.test.ts │ └── parent-lit-child-lit │ │ ├── dedupe-mixin.test.ts │ │ └── scoped-elements-mixin.test.ts ├── parent-html-child-html.test.ts ├── parent-html-child-lit.test.ts ├── parent-lit-child-html.test.ts └── parent-lit-child-lit.test.ts ├── tsconfig.json └── web-test-runner.config.js /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: ["main"] 9 | pull_request: 10 | branches: ["main"] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | matrix: 18 | node-version: [20.x, 22.x] 19 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 20 | 21 | steps: 22 | - uses: actions/checkout@v4 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v4 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | cache: "npm" 28 | - run: npm ci 29 | - run: npx playwright install --with-deps 30 | - run: npm run build --if-present 31 | - run: npm test 32 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish package to registries 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | # Allows you to run this workflow manually from the Actions tab 8 | workflow_dispatch: 9 | 10 | jobs: 11 | deploy: 12 | runs-on: ubuntu-latest 13 | permissions: 14 | id-token: write 15 | steps: 16 | - uses: actions/checkout@v4 17 | - name: Use Node.js ${{ matrix.node-version }} 18 | uses: actions/setup-node@v4 19 | with: 20 | node-version: 21.x 21 | registry-url: "https://registry.npmjs.org" 22 | cache: "npm" 23 | - run: npm ci 24 | - run: npm run build --if-present 25 | - run: npm version ${TAG_NAME} --git-tag-version=false 26 | env: 27 | TAG_NAME: ${{ github.event.release.tag_name }} 28 | - run: npm publish --provenance --access public 29 | env: 30 | NODE_AUTH_TOKEN: ${{ secrets.npm_token }} 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | node_modules/ 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @open-wc/context-protocol 2 | 3 | A Lit compatible implementation of the [context-protocol community protocol](https://github.com/webcomponents-cg/community-protocols/blob/main/proposals/context.md). 4 | 5 | ## Installation 6 | 7 | ```sh 8 | npm install --save @open-wc/context-protocol 9 | ``` 10 | 11 | ## Usage 12 | 13 | A component that implements the ProviderMixin will become a _Provider_ of data and a component that implements the ConsumerMixin will become a _Consumer_ of data. 14 | 15 | ```ts 16 | import { ProviderMixin } from "@open-wc/context-protocol"; 17 | 18 | export class ProviderElement extends ProviderMixin(HTMLElement) { 19 | // Set any data contexts here. 20 | contexts = { 21 | "number-of-unread-messages": () => { 22 | return 0; 23 | }, 24 | }; 25 | 26 | async connectedCallback() { 27 | // It's also possible to provide context at any point using `updateContext`. 28 | 29 | const response = await fetch("/api/messages/"); 30 | const data = await response.json(); 31 | this.updateContext("number-of-unread-messages", data.unreadCount); 32 | } 33 | } 34 | ``` 35 | 36 | ```ts 37 | import { ConsumerMixin } from "@open-wc/context-protocol"; 38 | 39 | export class ConsumerElement extends ConsumerMixin(HTMLElement) { 40 | contexts = { 41 | // Fetch contexts that we care about and subscribe to any changes. 42 | "number-of-unread-messages": (count: number) => { 43 | this.textContent = `${count} unread messages!`; 44 | }, 45 | }; 46 | 47 | connectedCallback() { 48 | // It's also possible to get any context on demand without subscribing. 49 | this.textContent = this.getContext("number-of-unread-messages"); 50 | } 51 | } 52 | ``` 53 | -------------------------------------------------------------------------------- /context-protocol.ts: -------------------------------------------------------------------------------- 1 | // From: https://github.com/webcomponents-cg/community-protocols/blob/main/proposals/context.md#definitions 2 | 3 | /** 4 | * A context key. 5 | * 6 | * A context key can be any type of object, including strings and symbols. The 7 | * Context type brands the key type with the `__context__` property that 8 | * carries the type of the value the context references. 9 | */ 10 | export type Context = KeyType & { __context__: ValueType }; 11 | 12 | /** 13 | * An unknown context type 14 | */ 15 | export type UnknownContext = Context; 16 | 17 | /** 18 | * A helper type which can extract a Context value type from a Context type 19 | */ 20 | export type ContextType = 21 | T extends Context ? V : never; 22 | 23 | /** 24 | * A function which creates a Context value object 25 | */ 26 | export const createContext = (key: unknown) => 27 | key as Context; 28 | 29 | /** 30 | * A callback which is provided by a context requester and is called with the value satisfying the request. 31 | * This callback can be called multiple times by context providers as the requested value is changed. 32 | */ 33 | export type ContextCallback = ( 34 | value: ValueType, 35 | unsubscribe?: () => void, 36 | ) => void; 37 | 38 | /** 39 | * An event fired by a context requester to signal it desires a named context. 40 | * 41 | * A provider should inspect the `context` property of the event to determine if it has a value that can 42 | * satisfy the request, calling the `callback` with the requested value if so. 43 | * 44 | * If the requested context event contains a truthy `subscribe` value, then a provider can call the callback 45 | * multiple times if the value is changed, if this is the case the provider should pass an `unsubscribe` 46 | * function to the callback which requesters can invoke to indicate they no longer wish to receive these updates. 47 | */ 48 | export class ContextRequestEvent extends Event { 49 | public constructor( 50 | public readonly context: T, 51 | public readonly callback: ContextCallback>, 52 | public readonly subscribe?: boolean, 53 | ) { 54 | super("context-request", { bubbles: true, composed: true }); 55 | } 56 | } 57 | 58 | /** 59 | * A 'context-request' event can be emitted by any element which desires 60 | * a context value to be injected by an external provider. 61 | */ 62 | declare global { 63 | interface WindowEventMap { 64 | "context-request": ContextRequestEvent; 65 | } 66 | interface ElementEventMap { 67 | "context-request": ContextRequestEvent; 68 | } 69 | interface HTMLElementEventMap { 70 | "context-request": ContextRequestEvent; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | import { ObservableMap } from "./observable-map.js"; 2 | import { 3 | createContext, 4 | Context, 5 | ContextRequestEvent, 6 | UnknownContext, 7 | } from "./context-protocol.js"; 8 | 9 | export interface CustomElement extends Element { 10 | connectedCallback?(): void; 11 | attributeChangedCallback?( 12 | name: string, 13 | oldValue: string | null, 14 | newValue: string | null, 15 | ): void; 16 | disconnectedCallback?(): void; 17 | adoptedCallback?(): void; 18 | formAssociatedCallback?(form: HTMLFormElement): void; 19 | formDisabledCallback?(disabled: boolean): void; 20 | formResetCallback?(): void; 21 | formStateRestoreCallback?( 22 | state: unknown, 23 | reason: "autocomplete" | "restore", 24 | ): void; 25 | } 26 | 27 | export declare type Constructor = new (...args: any[]) => T; 28 | 29 | type ProviderElement = CustomElement & { 30 | contexts?: Record unknown>; 31 | updateContext?(name: PropertyKey, value: unknown): void; 32 | }; 33 | 34 | type ConsumerElement = CustomElement & { 35 | contexts?: Record void>; 36 | }; 37 | 38 | export function ProviderMixin>( 39 | Class: T, 40 | ): T & Constructor { 41 | return class extends Class { 42 | #dataStore = new ObservableMap(); 43 | 44 | connectedCallback() { 45 | super.connectedCallback?.(); 46 | 47 | const contexts = "contexts" in this ? this.contexts : {}; 48 | 49 | for (const [key, value] of Object.entries(contexts || {})) { 50 | this.#dataStore.set(key, value()); 51 | } 52 | 53 | this.addEventListener("context-request", this.#handleContextRequest); 54 | } 55 | 56 | disconnectedCallback(): void { 57 | this.#dataStore = new ObservableMap(); 58 | this.removeEventListener("context-request", this.#handleContextRequest); 59 | } 60 | 61 | updateContext(name: PropertyKey, value: unknown) { 62 | this.#dataStore.set(createContext(name), value); 63 | } 64 | 65 | // We listen for a bubbled context request event and provide the event with the context requested. 66 | #handleContextRequest(event: ContextRequestEvent) { 67 | const subscribe = event.subscribe; 68 | const data = this.#dataStore.get(event.context); 69 | if (data) { 70 | event.stopPropagation(); 71 | 72 | let unsubscribe = () => undefined; 73 | 74 | if (subscribe) { 75 | unsubscribe = () => { 76 | data.subscribers.delete(event.callback); 77 | }; 78 | data.subscribers.add(event.callback); 79 | } 80 | 81 | event.callback(data.value, unsubscribe); 82 | } 83 | } 84 | }; 85 | } 86 | 87 | export function ConsumerMixin>( 88 | Class: T, 89 | ): T & Constructor { 90 | return class extends Class { 91 | #unsubscribes: Array<() => void> = []; 92 | 93 | getContext(contextName: PropertyKey) { 94 | let result: unknown; 95 | 96 | this.dispatchEvent( 97 | new ContextRequestEvent(createContext(contextName), (data) => { 98 | result = data; 99 | }), 100 | ); 101 | 102 | return result; 103 | } 104 | 105 | connectedCallback() { 106 | super.connectedCallback?.(); 107 | 108 | const contexts = "contexts" in this ? this.contexts : {}; 109 | for (const [contextName, callback] of Object.entries(contexts || {})) { 110 | const context = createContext(contextName); 111 | 112 | // We dispatch a event with that context. The event will bubble up the tree until it 113 | // reaches a component that is able to provide that value to us. 114 | // The event has a callback for the the value. 115 | this.dispatchEvent( 116 | new ContextRequestEvent( 117 | context, 118 | (data, unsubscribe) => { 119 | callback(data); 120 | if (unsubscribe) { 121 | this.#unsubscribes.push(unsubscribe); 122 | } 123 | }, 124 | // Always subscribe. Consumers can ignore updates if they'd like. 125 | true, 126 | ), 127 | ); 128 | } 129 | } 130 | 131 | // Unsubscribe from all callbacks when disconnecting 132 | disconnectedCallback() { 133 | for (const unsubscribe of this.#unsubscribes) { 134 | unsubscribe?.(); 135 | } 136 | // Empty out the array in case this element is still stored in memory but just not connected 137 | // to the DOM. 138 | this.#unsubscribes = []; 139 | } 140 | }; 141 | } 142 | -------------------------------------------------------------------------------- /observable-map.ts: -------------------------------------------------------------------------------- 1 | type Subscriber = (value: T) => void; 2 | 3 | export class ObservableMap { 4 | #store = new Map> }>(); 5 | 6 | set(key: K, value: V, subscribers = new Set>()) { 7 | const data = this.#store.get(key); 8 | subscribers = new Set([ 9 | ...subscribers, 10 | ...(data?.subscribers || new Set()), 11 | ]); 12 | 13 | this.#store.set(key, { value, subscribers }); 14 | for (const subscriber of subscribers) { 15 | subscriber(value); 16 | } 17 | } 18 | 19 | get(key: K) { 20 | return this.#store.get(key); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@open-wc/context-protocol", 3 | "version": "0.0.3", 4 | "description": "A Lit compatible implementation of the context-protocol community protocol", 5 | "type": "module", 6 | "scripts": { 7 | "build": "tsc", 8 | "test": "npm run test:types && npm run test:e2e", 9 | "test:e2e": "web-test-runner", 10 | "test:types": "tsc --noEmit" 11 | }, 12 | "files": ["./dist/"], 13 | "module": "./dist/index.js", 14 | "exports": { 15 | ".": { 16 | "default": "./dist/index.js", 17 | "types": "./dist/index.d.ts" 18 | } 19 | }, 20 | "repository": { 21 | "url": "https://github.com/open-wc/context-protocol" 22 | }, 23 | "keywords": [], 24 | "author": "", 25 | "license": "ISC", 26 | "devDependencies": { 27 | "@open-wc/dedupe-mixin": "^1.4.0", 28 | "@open-wc/scoped-elements": "^3.0.5", 29 | "@open-wc/testing": "^4.0.0", 30 | "@types/mocha": "^10.0.6", 31 | "@web/dev-server-esbuild": "^1.0.2", 32 | "@web/dev-server-polyfill": "^1.0.4", 33 | "@web/test-runner": "^0.18.0", 34 | "@web/test-runner-playwright": "^0.11.0", 35 | "chai": "^5.1.0", 36 | "lit": "^3.1.2", 37 | "mocha": "^10.4.0", 38 | "typescript": "^5.3.3" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /test/async.test.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 30 | 31 | 32 | 33 | 34 | Loading... 35 | 36 | 37 | 38 | 56 | 57 | 58 | -------------------------------------------------------------------------------- /test/fixture/html/consumer-element.ts: -------------------------------------------------------------------------------- 1 | import { ConsumerMixin } from "../../../index.js"; 2 | 3 | export class ConsumerElement extends ConsumerMixin(HTMLElement) { 4 | contexts = { 5 | "hit-count": (count: number) => { 6 | this.textContent = `${count} hits!`; 7 | }, 8 | }; 9 | } 10 | -------------------------------------------------------------------------------- /test/fixture/html/provider-element.ts: -------------------------------------------------------------------------------- 1 | import { ProviderMixin } from "../../../index.js"; 2 | 3 | export class ProviderElement extends ProviderMixin(HTMLElement) { 4 | contexts = { 5 | "hit-count": () => { 6 | return 9001; 7 | }, 8 | }; 9 | } 10 | -------------------------------------------------------------------------------- /test/fixture/lit/consumer-element.ts: -------------------------------------------------------------------------------- 1 | import { LitElement, html } from "lit"; 2 | import { ConsumerMixin } from "../../../index.js"; 3 | 4 | export class ConsumerElement extends ConsumerMixin(LitElement) { 5 | static get properties() { 6 | return { 7 | hitCount: { type: String }, 8 | }; 9 | } 10 | 11 | contexts = { 12 | "hit-count": (count: number) => { 13 | // @ts-expect-error 14 | this.hitCount = `${count} hits!`; 15 | }, 16 | }; 17 | 18 | constructor() { 19 | super(); 20 | // @ts-expect-error 21 | this.hitCount = "Loading..."; 22 | } 23 | 24 | render() { 25 | // @ts-expect-error 26 | return html`${this.hitCount}`; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /test/fixture/lit/provider-element.ts: -------------------------------------------------------------------------------- 1 | import { LitElement, html } from "lit"; 2 | import { ProviderMixin } from "../../../index.js"; 3 | 4 | export class ProviderElement extends ProviderMixin(LitElement) { 5 | contexts = { 6 | "hit-count": () => { 7 | return 9001; 8 | }, 9 | }; 10 | 11 | render() { 12 | return html``; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /test/mixins/parent-html-child-html/dedupe-mixin.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { fixture, waitUntil } from "@open-wc/testing"; 3 | import { ScopedElementsMixin } from "@open-wc/scoped-elements/html-element.js"; 4 | import { dedupeMixin } from "@open-wc/dedupe-mixin"; 5 | 6 | import { ProviderElement } from "../../fixture/html/provider-element.js"; 7 | import { ConsumerElement } from "../../fixture/html/consumer-element.js"; 8 | 9 | const ScopedElementsMixinDeduped = dedupeMixin(ScopedElementsMixin); 10 | 11 | it("DedupeMixin", async () => { 12 | window.customElements.define( 13 | "server-state", 14 | class extends ScopedElementsMixinDeduped(ProviderElement) {}, 15 | ); 16 | window.customElements.define( 17 | "hit-count", 18 | class extends ScopedElementsMixinDeduped(ConsumerElement) {}, 19 | ); 20 | 21 | const provider = await fixture( 22 | `Loading...`, 23 | ); 24 | const el = provider?.querySelector("hit-count"); 25 | 26 | await waitUntil(() => el?.textContent?.trim() !== "Loading..."); 27 | expect(el?.textContent).to.equal("9001 hits!"); 28 | 29 | provider?.updateContext?.("hit-count", 9002); 30 | await waitUntil(() => el?.textContent?.trim() !== "9001 hits!"); 31 | expect(el?.textContent).to.equal("9002 hits!"); 32 | }); 33 | -------------------------------------------------------------------------------- /test/mixins/parent-html-child-html/scoped-elements-mixin.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { fixture, waitUntil } from "@open-wc/testing"; 3 | import { ScopedElementsMixin } from "@open-wc/scoped-elements/html-element.js"; 4 | import { ProviderElement } from "../../fixture/html/provider-element.js"; 5 | import { ConsumerElement } from "../../fixture/html/consumer-element.js"; 6 | 7 | it("ScopedElementsMixin", async () => { 8 | window.customElements.define( 9 | "server-state", 10 | class extends ScopedElementsMixin(ProviderElement) {}, 11 | ); 12 | window.customElements.define( 13 | "hit-count", 14 | class extends ScopedElementsMixin(ConsumerElement) {}, 15 | ); 16 | const provider = await fixture( 17 | `Loading...`, 18 | ); 19 | const el = provider?.querySelector("hit-count"); 20 | 21 | await waitUntil(() => el?.textContent?.trim() !== "Loading..."); 22 | expect(el?.textContent).to.equal("9001 hits!"); 23 | 24 | provider?.updateContext?.("hit-count", 9002); 25 | await waitUntil(() => el?.textContent?.trim() !== "9001 hits!"); 26 | expect(el?.textContent).to.equal("9002 hits!"); 27 | }); 28 | -------------------------------------------------------------------------------- /test/mixins/parent-html-child-lit/dedupe-mixin.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { fixture, waitUntil } from "@open-wc/testing"; 3 | import { ScopedElementsMixin as HTMLScopedElementsMixin } from "@open-wc/scoped-elements/html-element.js"; 4 | import { ScopedElementsMixin as LitScopedElementsMixin } from "@open-wc/scoped-elements/lit-element.js"; 5 | import { dedupeMixin } from "@open-wc/dedupe-mixin"; 6 | 7 | import { ProviderElement } from "../../fixture/html/provider-element.js"; 8 | import { ConsumerElement } from "../../fixture/lit/consumer-element.js"; 9 | 10 | const HTMLScopedElementsMixinDeduped = dedupeMixin(HTMLScopedElementsMixin); 11 | const LitScopedElementsMixinDeduped = dedupeMixin(LitScopedElementsMixin); 12 | 13 | it("DedupeMixin", async () => { 14 | window.customElements.define( 15 | "server-state", 16 | class extends HTMLScopedElementsMixinDeduped(ProviderElement) {}, 17 | ); 18 | window.customElements.define( 19 | "hit-count", 20 | class extends LitScopedElementsMixinDeduped(ConsumerElement) {}, 21 | ); 22 | const provider = await fixture( 23 | `Loading...`, 24 | ); 25 | const el = provider?.querySelector("hit-count"); 26 | 27 | await waitUntil(() => el?.shadowRoot?.textContent?.trim() !== "Loading..."); 28 | expect(el?.shadowRoot?.textContent).to.equal("9001 hits!"); 29 | 30 | provider?.updateContext?.("hit-count", 9002); 31 | await waitUntil(() => el?.shadowRoot?.textContent?.trim() !== "9001 hits!"); 32 | expect(el?.shadowRoot?.textContent).to.equal("9002 hits!"); 33 | }); 34 | -------------------------------------------------------------------------------- /test/mixins/parent-html-child-lit/scoped-elements-mixin.test.ts: -------------------------------------------------------------------------------- 1 | import { LitElement, html } from "lit"; 2 | import { expect } from "chai"; 3 | import { fixture, waitUntil } from "@open-wc/testing"; 4 | import { ScopedElementsMixin as HTMLScopedElementsMixin } from "@open-wc/scoped-elements/html-element.js"; 5 | import { ScopedElementsMixin as LitScopedElementsMixin } from "@open-wc/scoped-elements/lit-element.js"; 6 | import { ProviderMixin, ConsumerMixin } from "../../../index.js"; 7 | 8 | class ProviderElement extends HTMLScopedElementsMixin( 9 | ProviderMixin(HTMLElement), 10 | ) { 11 | contexts = { 12 | "hit-count": () => { 13 | return 9001; 14 | }, 15 | }; 16 | } 17 | 18 | class ConsumerElement extends LitScopedElementsMixin( 19 | ConsumerMixin(LitElement), 20 | ) { 21 | static get properties() { 22 | return { 23 | hitCount: { type: String }, 24 | }; 25 | } 26 | 27 | contexts = { 28 | "hit-count": (count: number) => { 29 | // @ts-expect-error 30 | this.hitCount = `${count} hits!`; 31 | }, 32 | }; 33 | 34 | constructor() { 35 | super(); 36 | // @ts-expect-error 37 | this.hitCount = "Loading..."; 38 | } 39 | 40 | render() { 41 | // @ts-expect-error 42 | return html`${this.hitCount}`; 43 | } 44 | } 45 | 46 | it("ScopedElementsMixin", async () => { 47 | window.customElements.define( 48 | "server-state", 49 | HTMLScopedElementsMixin(ProviderElement), 50 | ); 51 | window.customElements.define( 52 | "hit-count", 53 | LitScopedElementsMixin(ConsumerElement), 54 | ); 55 | const provider = await fixture( 56 | `Loading...`, 57 | ); 58 | const el = provider?.querySelector("hit-count"); 59 | 60 | await waitUntil(() => el?.shadowRoot?.textContent?.trim() !== "Loading..."); 61 | expect(el?.shadowRoot?.textContent).to.equal("9001 hits!"); 62 | 63 | provider?.updateContext?.("hit-count", 9002); 64 | await waitUntil(() => el?.shadowRoot?.textContent?.trim() !== "9001 hits!"); 65 | expect(el?.shadowRoot?.textContent).to.equal("9002 hits!"); 66 | }); 67 | -------------------------------------------------------------------------------- /test/mixins/parent-lit-child-html/dedupe-mixin.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { fixture, waitUntil } from "@open-wc/testing"; 3 | import { ScopedElementsMixin } from "@open-wc/scoped-elements/html-element.js"; 4 | import { dedupeMixin } from "@open-wc/dedupe-mixin"; 5 | 6 | import { ProviderElement } from "../../fixture/html/provider-element.js"; 7 | import { ConsumerElement } from "../../fixture/html/consumer-element.js"; 8 | 9 | const ScopedElementsMixinDeduped = dedupeMixin(ScopedElementsMixin); 10 | 11 | it("DedupeMixin", async () => { 12 | window.customElements.define( 13 | "server-state", 14 | class extends ScopedElementsMixinDeduped(ProviderElement) {}, 15 | ); 16 | window.customElements.define( 17 | "hit-count", 18 | class extends ScopedElementsMixinDeduped(ConsumerElement) {}, 19 | ); 20 | 21 | const provider = await fixture( 22 | `Loading...`, 23 | ); 24 | const el = provider?.querySelector("hit-count"); 25 | 26 | await waitUntil(() => el?.textContent?.trim() !== "Loading..."); 27 | expect(el?.textContent).to.equal("9001 hits!"); 28 | 29 | provider?.updateContext?.("hit-count", 9002); 30 | await waitUntil(() => el?.textContent?.trim() !== "9001 hits!"); 31 | expect(el?.textContent).to.equal("9002 hits!"); 32 | }); 33 | -------------------------------------------------------------------------------- /test/mixins/parent-lit-child-html/scoped-elements-mixin.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { fixture, waitUntil } from "@open-wc/testing"; 3 | import { ScopedElementsMixin as LitScopedElementsMixin } from "@open-wc/scoped-elements/lit-element.js"; 4 | import { ScopedElementsMixin as HTMLScopedElementsMixin } from "@open-wc/scoped-elements/html-element.js"; 5 | import { ProviderElement } from "../../fixture/lit/provider-element.js"; 6 | import { ConsumerElement } from "../../fixture/html/consumer-element.js"; 7 | 8 | it("ScopedElementsMixin", async () => { 9 | window.customElements.define( 10 | "server-state", 11 | class extends LitScopedElementsMixin(ProviderElement) {}, 12 | ); 13 | window.customElements.define( 14 | "hit-count", 15 | class extends HTMLScopedElementsMixin(ConsumerElement) {}, 16 | ); 17 | const provider = await fixture( 18 | `Loading...`, 19 | ); 20 | const el = provider?.querySelector("hit-count"); 21 | 22 | await waitUntil(() => el?.textContent?.trim() !== "Loading..."); 23 | expect(el?.textContent).to.equal("9001 hits!"); 24 | 25 | provider?.updateContext?.("hit-count", 9002); 26 | await waitUntil(() => el?.textContent?.trim() !== "9001 hits!"); 27 | expect(el?.textContent).to.equal("9002 hits!"); 28 | }); 29 | -------------------------------------------------------------------------------- /test/mixins/parent-lit-child-lit/dedupe-mixin.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { fixture, waitUntil } from "@open-wc/testing"; 3 | import { ScopedElementsMixin } from "@open-wc/scoped-elements/lit-element.js"; 4 | import { dedupeMixin } from "@open-wc/dedupe-mixin"; 5 | 6 | import { ProviderElement } from "../../fixture/lit/provider-element.js"; 7 | import { ConsumerElement } from "../../fixture/lit/consumer-element.js"; 8 | 9 | const ScopedElementsMixinDeduped = dedupeMixin(ScopedElementsMixin); 10 | 11 | it("DedupeMixin", async () => { 12 | window.customElements.define( 13 | "server-state", 14 | class extends ScopedElementsMixinDeduped(ProviderElement) {}, 15 | ); 16 | window.customElements.define( 17 | "hit-count", 18 | class extends ScopedElementsMixinDeduped(ConsumerElement) {}, 19 | ); 20 | 21 | const provider = await fixture( 22 | `Loading...`, 23 | ); 24 | const el = provider?.querySelector("hit-count"); 25 | 26 | await waitUntil(() => el?.shadowRoot?.textContent?.trim() !== "Loading..."); 27 | expect(el?.shadowRoot?.textContent).to.equal("9001 hits!"); 28 | 29 | provider?.updateContext?.("hit-count", 9002); 30 | await waitUntil(() => el?.shadowRoot?.textContent?.trim() !== "9001 hits!"); 31 | expect(el?.shadowRoot?.textContent).to.equal("9002 hits!"); 32 | }); 33 | -------------------------------------------------------------------------------- /test/mixins/parent-lit-child-lit/scoped-elements-mixin.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { fixture, waitUntil } from "@open-wc/testing"; 3 | import { ScopedElementsMixin } from "@open-wc/scoped-elements/lit-element.js"; 4 | import { ProviderElement } from "../../fixture/lit/provider-element.js"; 5 | import { ConsumerElement } from "../../fixture/lit/consumer-element.js"; 6 | 7 | it("ScopedElementsMixin", async () => { 8 | window.customElements.define( 9 | "server-state", 10 | class extends ScopedElementsMixin(ProviderElement) {}, 11 | ); 12 | window.customElements.define( 13 | "hit-count", 14 | class extends ScopedElementsMixin(ConsumerElement) {}, 15 | ); 16 | const provider = await fixture( 17 | `Loading...`, 18 | ); 19 | const el = provider?.querySelector("hit-count"); 20 | 21 | await waitUntil(() => el?.shadowRoot?.textContent?.trim() !== "Loading..."); 22 | expect(el?.shadowRoot?.textContent).to.equal("9001 hits!"); 23 | 24 | provider?.updateContext?.("hit-count", 9002); 25 | await waitUntil(() => el?.shadowRoot?.textContent?.trim() !== "9001 hits!"); 26 | expect(el?.shadowRoot?.textContent).to.equal("9002 hits!"); 27 | }); 28 | -------------------------------------------------------------------------------- /test/parent-html-child-html.test.ts: -------------------------------------------------------------------------------- 1 | /* globals describe, it */ 2 | 3 | import { expect } from "chai"; 4 | import { fixture, waitUntil } from "@open-wc/testing"; 5 | 6 | import { ProviderElement } from "./fixture/html/provider-element.js"; 7 | import { ConsumerElement } from "./fixture/html/consumer-element.js"; 8 | 9 | window.customElements.define("server-state", ProviderElement); 10 | window.customElements.define("hit-count", ConsumerElement); 11 | 12 | describe("Parent[HTML] => Child[HTML]", () => { 13 | it("subscribes to changes", async () => { 14 | const provider = await fixture( 15 | `Loading...`, 16 | ); 17 | const el = provider?.querySelector("hit-count"); 18 | 19 | await waitUntil(() => el?.textContent?.trim() !== "Loading..."); 20 | expect(el?.textContent).to.equal("9001 hits!"); 21 | 22 | provider?.updateContext?.("hit-count", 9002); 23 | await waitUntil(() => el?.textContent?.trim() !== "9001 hits!"); 24 | expect(el?.textContent).to.equal("9002 hits!"); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /test/parent-html-child-lit.test.ts: -------------------------------------------------------------------------------- 1 | /* globals describe, it */ 2 | 3 | import { expect } from "chai"; 4 | import { fixture, waitUntil } from "@open-wc/testing"; 5 | 6 | import { ProviderElement } from "./fixture/html/provider-element.js"; 7 | import { ConsumerElement } from "./fixture/lit/consumer-element.js"; 8 | 9 | window.customElements.define("server-state", ProviderElement); 10 | window.customElements.define("hit-count", ConsumerElement); 11 | 12 | describe("Parent[HTML] => Child[Lit]", () => { 13 | it("subscribes to changes", async () => { 14 | const provider = await fixture( 15 | `Loading...`, 16 | ); 17 | const el = provider?.querySelector("hit-count"); 18 | 19 | await waitUntil(() => el?.shadowRoot?.textContent?.trim() !== "Loading..."); 20 | expect(el?.shadowRoot?.textContent).to.equal("9001 hits!"); 21 | 22 | provider?.updateContext?.("hit-count", 9002); 23 | await waitUntil(() => el?.shadowRoot?.textContent?.trim() !== "9001 hits!"); 24 | expect(el?.shadowRoot?.textContent).to.equal("9002 hits!"); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /test/parent-lit-child-html.test.ts: -------------------------------------------------------------------------------- 1 | /* globals describe, it */ 2 | 3 | import { expect } from "chai"; 4 | import { fixture, waitUntil } from "@open-wc/testing"; 5 | 6 | import { ProviderElement } from "./fixture/lit/provider-element.js"; 7 | import { ConsumerElement } from "./fixture/html/consumer-element.js"; 8 | 9 | window.customElements.define("server-state", ProviderElement); 10 | window.customElements.define("hit-count", ConsumerElement); 11 | 12 | describe("Parent[Lit] => Child[HTML]", () => { 13 | it("subscribes to changes", async () => { 14 | const provider = await fixture( 15 | `Loading...`, 16 | ); 17 | const el = provider?.querySelector("hit-count"); 18 | 19 | await waitUntil(() => el?.textContent?.trim() !== "Loading..."); 20 | expect(el?.textContent).to.equal("9001 hits!"); 21 | 22 | provider?.updateContext?.("hit-count", 9002); 23 | await waitUntil(() => el?.textContent?.trim() !== "9001 hits!"); 24 | expect(el?.textContent).to.equal("9002 hits!"); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /test/parent-lit-child-lit.test.ts: -------------------------------------------------------------------------------- 1 | /* globals describe, it */ 2 | 3 | import { expect } from "chai"; 4 | import { fixture, waitUntil } from "@open-wc/testing"; 5 | 6 | import { ProviderElement } from "./fixture/lit/provider-element.js"; 7 | import { ConsumerElement } from "./fixture/lit/consumer-element.js"; 8 | 9 | window.customElements.define("server-state", ProviderElement); 10 | window.customElements.define("hit-count", ConsumerElement); 11 | 12 | describe("Parent[Lit] => Child[Lit]", () => { 13 | it("subscribes to changes", async () => { 14 | const provider = await fixture( 15 | `Loading...`, 16 | ); 17 | const el = provider?.querySelector("hit-count"); 18 | 19 | await waitUntil(() => el?.shadowRoot?.textContent?.trim() !== "Loading..."); 20 | expect(el?.shadowRoot?.textContent).to.equal("9001 hits!"); 21 | 22 | provider?.updateContext?.("hit-count", 9002); 23 | await waitUntil(() => el?.shadowRoot?.textContent?.trim() !== "9001 hits!"); 24 | expect(el?.shadowRoot?.textContent).to.equal("9002 hits!"); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "compilerOptions": { 4 | "outDir": "./dist/", 5 | "target": "esnext", 6 | "declaration": true, 7 | "module": "esnext", 8 | "strict": true, 9 | "esModuleInterop": true, 10 | "skipLibCheck": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "lib": ["esnext", "dom"], 13 | "checkJs": true, 14 | "isolatedModules": true, 15 | "moduleResolution": "Bundler", 16 | "experimentalDecorators": true 17 | }, 18 | "include": ["./*.ts", "./test/*.ts"], 19 | "isolatedModules": true 20 | } 21 | -------------------------------------------------------------------------------- /web-test-runner.config.js: -------------------------------------------------------------------------------- 1 | import { polyfill } from "@web/dev-server-polyfill"; 2 | import { playwrightLauncher } from "@web/test-runner-playwright"; 3 | import { esbuildPlugin } from "@web/dev-server-esbuild"; 4 | 5 | export default { 6 | files: ["test/**/*.test.{js,ts}"], 7 | nodeResolve: true, 8 | plugins: [ 9 | esbuildPlugin({ ts: true }), 10 | polyfill({ 11 | scopedCustomElementRegistry: true, 12 | }), 13 | ], 14 | filterBrowserLogs(log) { 15 | return ( 16 | log.args[0] !== 17 | "Lit is in dev mode. Not recommended for production! See https://lit.dev/msg/dev-mode for more information." 18 | ); 19 | }, 20 | browsers: [ 21 | playwrightLauncher({ product: "chromium" }), 22 | playwrightLauncher({ product: "firefox" }), 23 | playwrightLauncher({ product: "webkit" }), 24 | ], 25 | fullyParallel: true, 26 | }; 27 | --------------------------------------------------------------------------------