Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merge mixins working branch #1

Merged
merged 9 commits into from
Mar 29, 2024
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
13 changes: 13 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
- [ ] Add a test to make sure both of these work:
this.shadowRoot.innerHTML = `<slot></slot>`;
this.shadowRoot.innerHTML = `<some-html></some-html>`;
- [ ] Think about clashes if there are multiple providers or listeners for the `request-context` event.
- [ ] Try abstracting the data store into something provided by the ProvidableMixin.
- [ ] Gather more feedback.
- [ ] Plan another meeting.
- [ ] Put this source code somewhere and share with ShareWare.
- [ ] Think about `async` providables.
- [ ] How does the singletonmanager work??
- It's in Lion?
- [ ] This should provide data globally and child components shouldn't provide data.
- [ ] Introduce ownership at the top of the thing at the providable mixin.
18 changes: 18 additions & 0 deletions context-protocol.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
type Subscriber<T> = (value: T) => void;

export class ObservableMap {
#store = new Map<string, {value: unknown, subscribers: Set<Subscriber<unknown>>}>

set(key: string, value: unknown, subscribers = new Set<Subscriber<unknown>>()) {
const data = this.#store.get(key);
subscribers = new Set([...subscribers, ...(data?.subscribers || new Set())]);
this.#store.set(key, {value, subscribers});
for (const subscriber of subscribers) {
subscriber(value);
}
}

get(key: string) {
return this.#store.get(key);
}
}
5 changes: 0 additions & 5 deletions deno.json

This file was deleted.

116 changes: 116 additions & 0 deletions mixins.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { ObservableMap } from "./context-protocol.js";
import { createContext, ContextEvent, UnknownContext } from "./index.js";

interface CustomElement extends HTMLElement {
new (...args: any[]): CustomElement;
observedAttributes: string[];
connectedCallback?(): void;
attributeChangedCallback?(
name: string,
oldValue: string | null,
newValue: string | null,
): void;
disconnectedCallback?(): void;
adoptedCallback?(): void;
}

export function ProviderMixin(Class: CustomElement) {
return class extends Class {
#dataStore = new ObservableMap();

connectedCallback() {
super.connectedCallback?.();

// @ts-expect-error todo
for (const [key, value] of Object.entries(this.contexts || {})) {
// @ts-expect-error todo
this.#dataStore.set(key, value());
}

this.addEventListener('context-request', this);
}

disconnectedCallback(): void {
this.#dataStore = new ObservableMap();
}

handleEvent(event: Event) {
if (event.type === "context-request") {
this.#handleContextRequest(event as ContextEvent<UnknownContext>);
}
}

updateContext(name: string, value: unknown) {
this.#dataStore.set(name, value);
}

// We listen for a bubbled context request event and provide the event with the context requested.
#handleContextRequest(
event: ContextEvent<{ name: string; initialValue?: unknown }>,
) {
const { name, initialValue } = event.context;
const subscribe = event.subscribe;
if (initialValue) {
this.#dataStore.set(name, initialValue);
}
const data = this.#dataStore.get(name);
if (data) {
event.stopPropagation();

let unsubscribe = () => undefined;

if (subscribe) {
unsubscribe = () => {
data.subscribers.delete(event.callback);
};
data.subscribers.add(event.callback);
}

event.callback(data.value, unsubscribe);
}
}
};
}

export function ConsumerMixin(Class: CustomElement) {
return class extends Class {
unsubscribes: Array<() => void> = [];

connectedCallback() {
super.connectedCallback?.();

// @ts-expect-error don't worry about it babe
for (const [contextName, callback] of Object.entries(this.contexts)) {
const context = createContext(contextName);

// We dispatch a event with that context. The event will bubble up the tree until it
// reaches a component that is able to provide that value to us.
// The event has a callback for the the value.
this.dispatchEvent(
new ContextEvent(
context,
(data, unsubscribe) => {
// @ts-expect-error
callback(data);
if (unsubscribe) {
this.unsubscribes.push(unsubscribe);
}
},
// Always subscribe. Consumers can ignore updates if they'd like.
true,
),
);
}
}

// Unsubscribe from all callbacks when disconnecting
disconnectedCallback() {
for (const unsubscribe of this.unsubscribes) {
unsubscribe?.();
}
// Empty out the array in case this element is still stored in memory but just not connected
// to the DOM.
this.unsubscribes = [];
}
};
}
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@
".": {
"default": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"mixins": {
"default": "./dist/mixins.js",
"types": "./dist/mixins.d.ts"
}
},
"keywords": [],
Expand All @@ -24,6 +28,7 @@
"@web/test-runner": "^0.18.0",
"@web/test-runner-playwright": "^0.11.0",
"chai": "^5.1.0",
"lit": "^3.1.2",
"typescript": "^5.3.3"
}
}
57 changes: 57 additions & 0 deletions test/async.test.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<!doctype html>
<html>
<head>
<script type="module">
import { ProviderMixin, ConsumerMixin } from '../../mixins.ts';

function wait(ms) {
return new Promise(resolve => {
setTimeout(resolve, ms);
});
}

window.customElements.define('server-state', class ServerState extends ProviderMixin(HTMLElement) {
contexts = {
'hit-count': () => {
return 9001;
}
}
});

window.customElements.define('hit-count', class HitCount extends ConsumerMixin(HTMLElement) {
contexts = {
'hit-count': async (count) => {
await wait(10);
this.textContent = `${count} hits!`;
}
};
});
</script>
</head>
<body>
<server-state>
<hit-count>
Loading...
</hit-count>
</server-state>

<script type="module">
import { runTests } from "@web/test-runner-mocha";
import { expect } from "chai";
import { waitUntil } from '@open-wc/testing';

runTests(() => {
it("subscribes to changes", async () => {
const provider = document.querySelector('server-state');
const el = document.querySelector('hit-count');
await waitUntil(() => el.textContent.trim() !== 'Loading...');
expect(el.textContent).to.equal('9001 hits!');

provider.updateContext('hit-count', 9002);
await waitUntil(() => el.textContent.trim() !== '9001 hits!');
expect(el.textContent).to.equal('9002 hits!');
});
});
</script>
</body>
</html>
50 changes: 50 additions & 0 deletions test/parent-html-child-html.test.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<!doctype html>
<html>
<head>
<script type="module">
import { ProviderMixin, ConsumerMixin } from '../../mixins.ts';

window.customElements.define('server-state', class ServerState extends ProviderMixin(HTMLElement) {
contexts = {
'hit-count': () => {
return 9001;
}
}
});

window.customElements.define('hit-count', class HitCount extends ConsumerMixin(HTMLElement) {
contexts = {
'hit-count': (count) => {
this.textContent = `${count} hits!`;
}
};
});
</script>
</head>
<body>
<server-state>
<hit-count>
Loading...
</hit-count>
</server-state>

<script type="module">
import { runTests } from "@web/test-runner-mocha";
import { expect } from "chai";
import { waitUntil } from '@open-wc/testing';

runTests(() => {
it("subscribes to changes", async () => {
const provider = document.querySelector('server-state');
const el = document.querySelector('hit-count');
await waitUntil(() => el.textContent.trim() !== 'Loading...');
expect(el.textContent).to.equal('9001 hits!');

provider.updateContext('hit-count', 9002);
await waitUntil(() => el.textContent.trim() !== '9001 hits!');
expect(el.textContent).to.equal('9002 hits!');
});
});
</script>
</body>
</html>
64 changes: 64 additions & 0 deletions test/parent-html-child-lit.test.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<!doctype html>
<html>
<head>
<script type="module">
import { ProviderMixin, ConsumerMixin } from '../../mixins.ts';
import {html, css, LitElement} from 'lit';

window.customElements.define('server-state', class ServerState extends ProviderMixin(HTMLElement) {
contexts = {
'hit-count': () => {
return 9001;
}
}
});

window.customElements.define('hit-count', class HitCount extends ConsumerMixin(LitElement) {
static properties = {
hits: {type: Number},
};

contexts = {
'hit-count': (count) => {
this.textContent = `${count} hits!`;
}
};

constructor() {
super();
this.hits = 'Loading...';
}

render() {
return html`<p>${this.hits} hits!</p>`;
}
});
</script>
</head>
<body>
<server-state>
<hit-count>
Loading...
</hit-count>
</server-state>

<script type="module">
import { runTests } from "@web/test-runner-mocha";
import { expect } from "chai";
import { waitUntil } from '@open-wc/testing';

runTests(() => {
it("subscribes to changes", async () => {
const provider = document.querySelector('server-state');
const el = document.querySelector('hit-count');
await waitUntil(() => el.textContent.trim() !== 'Loading...');
expect(el.textContent).to.equal('9001 hits!');

provider.updateContext('hit-count', 9002);
await waitUntil(() => el.textContent.trim() !== '9001 hits!');
expect(el.textContent).to.equal('9002 hits!');
});
});
</script>
</body>
</html>
Loading