From d98395b2905ee7fa9bb6790efb365e20c50962df Mon Sep 17 00:00:00 2001 From: Michael Potter Date: Wed, 18 Dec 2024 10:52:43 -0500 Subject: [PATCH 01/13] feat: adding List class --- packages/signals/src/index.ts | 39 +++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/packages/signals/src/index.ts b/packages/signals/src/index.ts index e1a1ed0..c1d71af 100644 --- a/packages/signals/src/index.ts +++ b/packages/signals/src/index.ts @@ -76,6 +76,44 @@ export class Computed any, P extends State[]> e } } +export class List> extends State { + override get value() { + return super.value; + } + + override set value(value: T[]) { + if (this._value === value) return; + this._value = value; + this.dispatchEvent(new Event('updated')); + } + + add(signal: T) { + this.value.push(signal); + signal.addEventListener('updated', () => this.dispatchEvent(new Event('updated'))); + this.dispatchEvent(new Event('updated')); + } + + remove(signal: T) { + const index = this.value.indexOf(signal); + if (index > -1) { + this.value.splice(index, 1); + signal.removeEventListener('updated', () => this.dispatchEvent(new Event('updated'))); + this.dispatchEvent(new Event('updated')); + } + } + + override async *stream() { + yield this.value; + while (true) { + const targets = [this, ...this.value]; + await Promise.race(targets.map(signal => + new Promise(resolve => signal.addEventListener('updated', () => resolve(), { once: true })) + )); + yield this.value; + } + } +} + /** * Signal object that contains State and Computed classes * @example @@ -85,4 +123,5 @@ export class Computed any, P extends State[]> e export class Signal { static State = State; static Computed = Computed; + static List = List; } From 4ce67d5e41b191cd8290dcff4e58a861f5d3ede0 Mon Sep 17 00:00:00 2001 From: Michael Potter Date: Thu, 19 Dec 2024 22:18:19 -0500 Subject: [PATCH 02/13] checkin --- packages/signals/package.json | 3 + packages/signals/src/index-old.ts | 139 ++++++++++++++++++++++++++++++ packages/signals/src/index.ts | 60 +++++++++---- 3 files changed, 184 insertions(+), 18 deletions(-) create mode 100644 packages/signals/src/index-old.ts diff --git a/packages/signals/package.json b/packages/signals/package.json index 613a46f..e780efd 100644 --- a/packages/signals/package.json +++ b/packages/signals/package.json @@ -54,5 +54,8 @@ "peerDependencies": { "@types/react": "^18.2.23", "react": "^18.2.0" + }, + "dependencies": { + "msw": "^2.7.0" } } diff --git a/packages/signals/src/index-old.ts b/packages/signals/src/index-old.ts new file mode 100644 index 0000000..740d7ee --- /dev/null +++ b/packages/signals/src/index-old.ts @@ -0,0 +1,139 @@ +export class SignalUpdatedEvent extends Event { + constructor(public oldValue: T, public newValue: T) { + super('updated'); + } +} + +export class State extends EventTarget { + _value: T; + + /** + * Creates a new signal + * + * @param { T } value Initial value of the Signal + * @example + * const counter = new Signal.State(0) + */ + constructor(value: T) { + super(); + this._value = value; + } + + get value() { + return this._value; + } + + set value(value: T) { + const prevValue = this._value; + // dirty check + if (prevValue !== value) { + this._value = value; + this.dispatchEvent(new SignalUpdatedEvent(prevValue, this._value)); + } + } + + /** + * Async generator that yields the current value of the Signal and waits for the next update + * @example + * for await (const value of counter.stream()) { + * } + */ + async *stream() { + yield this.value; + while (true) { + yield new Promise(resolve => this.addEventListener('updated', () => resolve(this.value), { once: true })); + } + } + + /** + * Async iterator that yields the current value + * @example + * for await (const value of counter) { + * } + */ + [Symbol.asyncIterator]() { + return this.stream(); + } +} + +export class Computed any, P extends State[]> extends State> { + /** + * Computed Signal + * @param { Function } fn Function that computes the value of the Signal + * @param { State[] } props An array of dependencies that are instances of either Signal.State or Signal.Computed + * @example + * const isDone = Signal.Computed(() => counter.value > 9, [counter]) + */ + constructor(private fn: F, props: P) { + super(fn()); + props.forEach(prop => this.watcher(prop)); + } + + async watcher(prop: State) { + for await (const _ of prop) { + this.value = this.fn(); + } + } +} + +export class List> extends State { + override get value() { + return super.value; + } + + override set value(value: T[]) { + if (this._value === value) return; + this._value = value; + this.dispatchEvent(new Event('updated')); + } + + // add(signal: T) { + // this.value.push(signal); + // signal.addEventListener('updated', () => this.dispatchEvent(new Event('updated'))); + // this.dispatchEvent(new Event('updated')); + // } + + // remove(signal: T) { + // const index = this.value.indexOf(signal); + // if (index > -1) { + // this.value.splice(index, 1); + // signal.removeEventListener('updated', () => this.dispatchEvent(new Event('updated'))); + // this.dispatchEvent(new Event('updated')); + // } + // } + + override async *stream() { + yield this.value; + while (true) { + const targets = [this, ...this.value]; + const ac = new AbortController(); + await Promise.race(targets.map(target => + new Promise(resolve => target.addEventListener('updated', () => resolve(), { once: true, signal: ac.signal })) + )); + ac.abort(); + yield this.value; + } + } + // override async *stream() { + // yield this.value; + // while (true) { + // const targets = [this, ...this.value]; + // await Promise.race(targets.map(t => + // new Promise(resolve => t.addEventListener('updated', () => resolve(), { once: true })) + // )); + // yield this.value; + // } + // } +} + +/** + * Signal object that contains State and Computed classes + * @example + * const counter = Signal.State(0) + * const isDone = Signal.Computed(() => counter.value > 9, [counter]) + */ +export class Signal { + static State = State; + static Computed = Computed; + static List = List; +} diff --git a/packages/signals/src/index.ts b/packages/signals/src/index.ts index c1d71af..0624c89 100644 --- a/packages/signals/src/index.ts +++ b/packages/signals/src/index.ts @@ -1,3 +1,5 @@ +console.log('hi') + export class SignalUpdatedEvent extends Event { constructor(public oldValue: T, public newValue: T) { super('updated'); @@ -7,6 +9,8 @@ export class SignalUpdatedEvent extends Event { export class State extends EventTarget { _value: T; + _abortCtrl = new AbortController(); + /** * Creates a new signal * @@ -41,7 +45,7 @@ export class State extends EventTarget { async *stream() { yield this.value; while (true) { - yield new Promise(resolve => this.addEventListener('updated', () => resolve(this.value), { once: true })); + yield new Promise(resolve => this.addEventListener('updated', () => resolve(this.value), { once: true, signal: this._abortCtrl.signal })); } } @@ -54,6 +58,10 @@ export class State extends EventTarget { [Symbol.asyncIterator]() { return this.stream(); } + + disconnect() { + this._abortCtrl.abort(); + } } export class Computed any, P extends State[]> extends State> { @@ -83,35 +91,51 @@ export class List> extends State { override set value(value: T[]) { if (this._value === value) return; + this._value.forEach(i => i.disconnect()); this._value = value; this.dispatchEvent(new Event('updated')); } - add(signal: T) { - this.value.push(signal); - signal.addEventListener('updated', () => this.dispatchEvent(new Event('updated'))); - this.dispatchEvent(new Event('updated')); - } - - remove(signal: T) { - const index = this.value.indexOf(signal); - if (index > -1) { - this.value.splice(index, 1); - signal.removeEventListener('updated', () => this.dispatchEvent(new Event('updated'))); - this.dispatchEvent(new Event('updated')); - } - } - override async *stream() { yield this.value; while (true) { const targets = [this, ...this.value]; - await Promise.race(targets.map(signal => - new Promise(resolve => signal.addEventListener('updated', () => resolve(), { once: true })) + const ac = new AbortController(); + this._abortCtrl.signal.onabort = () => { + ac.abort(); + } + await Promise.race(targets.map(target => + new Promise(resolve => target.addEventListener('updated', () => { + resolve() + }, { once: true, signal: ac.signal })) )); + ac.abort(); yield this.value; } } + + // override async *stream() { + // yield this.value; + // while (true) { + // const targets = [this, ...this.value]; + // const ac = new AbortController(); + // this._abortCtrl.signal.onabort = () => { + // ac.abort(); + // } + // await Promise.race(targets.map(target => + // new Promise(resolve => target.addEventListener('updated', () => { + // resolve() + // }, { once: true, signal: ac.signal })) + // )); + // ac.abort(); + // yield this.value; + // } + // } + + override disconnect(): void { + this.value.forEach(i => i.disconnect()); + super.disconnect(); + } } /** From e9bd2d3bedb9d8bfe21ce31b9c39c07b5d9dcec1 Mon Sep 17 00:00:00 2001 From: Michael Potter Date: Thu, 19 Dec 2024 22:18:55 -0500 Subject: [PATCH 03/13] chore: update sandbox --- packages/sandbox/newname | 51 +++ packages/sandbox/package.json | 21 +- packages/sandbox/public/mockServiceWorker.js | 307 ++++++++++++++++++ .../sandbox/src/elements/my-params-form.ts | 105 ++++++ packages/sandbox/src/elements/my-user.ts | 62 ++++ packages/sandbox/src/index.css | 13 +- packages/sandbox/src/lib/fetchWrapper.ts | 178 ++++++++++ packages/sandbox/src/lib/random.ts | 20 ++ packages/sandbox/src/mocks/browser.ts | 5 + packages/sandbox/src/mocks/data/users.ts | 13 + packages/sandbox/src/mocks/handlers.ts | 44 +++ packages/sandbox/src/mocks/init.ts | 13 + .../sandbox/src/mocks/queryParamOptions.ts | 21 ++ packages/sandbox/src/my-doubler.ts | 31 -- packages/sandbox/src/my-element.ts | 294 +++++++++-------- packages/sandbox/src/my-form.ts | 46 --- packages/sandbox/src/my-late-signal.ts | 44 --- packages/sandbox/src/services/user.ts | 89 +++++ packages/sandbox/src/services/users.ts | 50 +++ packages/sandbox/src/store.ts | 29 -- packages/sandbox/src/types.ts | 6 + packages/sandbox/tsconfig.json | 11 +- packages/sandbox/vite.config.ts | 7 + 23 files changed, 1163 insertions(+), 297 deletions(-) create mode 100644 packages/sandbox/newname create mode 100644 packages/sandbox/public/mockServiceWorker.js create mode 100644 packages/sandbox/src/elements/my-params-form.ts create mode 100644 packages/sandbox/src/elements/my-user.ts create mode 100644 packages/sandbox/src/lib/fetchWrapper.ts create mode 100644 packages/sandbox/src/lib/random.ts create mode 100644 packages/sandbox/src/mocks/browser.ts create mode 100644 packages/sandbox/src/mocks/data/users.ts create mode 100644 packages/sandbox/src/mocks/handlers.ts create mode 100644 packages/sandbox/src/mocks/init.ts create mode 100644 packages/sandbox/src/mocks/queryParamOptions.ts delete mode 100644 packages/sandbox/src/my-doubler.ts delete mode 100644 packages/sandbox/src/my-form.ts delete mode 100644 packages/sandbox/src/my-late-signal.ts create mode 100644 packages/sandbox/src/services/user.ts create mode 100644 packages/sandbox/src/services/users.ts delete mode 100644 packages/sandbox/src/store.ts create mode 100644 packages/sandbox/src/types.ts create mode 100644 packages/sandbox/vite.config.ts diff --git a/packages/sandbox/newname b/packages/sandbox/newname new file mode 100644 index 0000000..1599c80 --- /dev/null +++ b/packages/sandbox/newname @@ -0,0 +1,51 @@ +import { SignalUpdatedEvent, Computed, List } from '@heymp/signals'; +import { User } from '../types.js'; +import { fetchWrapper } from '../lib/fetchWrapper.js'; +import { UserSignal } from './user.js'; + +export const usersSignalStates = ['initial', 'updating', 'complete', 'error'] as const; + +export type UsersSignalState = typeof usersSignalStates[number]; + +export class UsersSignal extends List { + constructor(value: UserSignal[]) { + super(value); + } + + childStates = new Computed(() => { + return new Set(this.value?.map(i => i.state)); + }, [this]); + + error?: Error; + + #state: UsersSignalState = 'initial'; + + get state() { + return this.#state; + } + + set state(state: UsersSignalState) { + const prev = this.#state; + if (prev !== state) { + this.#state = state; + this.dispatchEvent(new SignalUpdatedEvent(prev, this.#state)); + } + } + + async update() { + this.state = 'updating'; + this.value = []; + const { data, error } = await fetchWrapper({ url: 'https://example.com/users' }); + + if (error) { + this.state = 'error'; + return { error }; + } + + this.value = data.map(i => new UserSignal(i)); + + this.state = 'complete'; + + return { data }; + } +} diff --git a/packages/sandbox/package.json b/packages/sandbox/package.json index 18ec2f9..0edd428 100644 --- a/packages/sandbox/package.json +++ b/packages/sandbox/package.json @@ -1,5 +1,5 @@ { - "name": "sandbox", + "name": "mock-service-worker", "private": true, "version": "0.0.0", "type": "module", @@ -9,12 +9,19 @@ "preview": "vite preview" }, "dependencies": { - "@lit/task": "^1.0.0", - "@heymp/signals": "0.1.1", - "lit": "^3.1.2" + "@heymp/signals": "^0.1.1", + "lit": "^3.2.1", + "ts-pattern": "^5.5.0" }, "devDependencies": { - "typescript": "^5.2.2", - "vite": "^5.2.0" + "@faker-js/faker": "^9.2.0", + "msw": "^2.6.1", + "typescript": "~5.6.2", + "vite": "^5.4.10" + }, + "msw": { + "workerDirectory": [ + "public" + ] } -} +} \ No newline at end of file diff --git a/packages/sandbox/public/mockServiceWorker.js b/packages/sandbox/public/mockServiceWorker.js new file mode 100644 index 0000000..ec47a9a --- /dev/null +++ b/packages/sandbox/public/mockServiceWorker.js @@ -0,0 +1,307 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker. + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + * - Please do NOT serve this file on production. + */ + +const PACKAGE_VERSION = '2.7.0' +const INTEGRITY_CHECKSUM = '00729d72e3b82faf54ca8b9621dbb96f' +const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') +const activeClientIds = new Set() + +self.addEventListener('install', function () { + self.skipWaiting() +}) + +self.addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()) +}) + +self.addEventListener('message', async function (event) { + const clientId = event.source.id + + if (!clientId || !self.clients) { + return + } + + const client = await self.clients.get(clientId) + + if (!client) { + return + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + switch (event.data) { + case 'KEEPALIVE_REQUEST': { + sendToClient(client, { + type: 'KEEPALIVE_RESPONSE', + }) + break + } + + case 'INTEGRITY_CHECK_REQUEST': { + sendToClient(client, { + type: 'INTEGRITY_CHECK_RESPONSE', + payload: { + packageVersion: PACKAGE_VERSION, + checksum: INTEGRITY_CHECKSUM, + }, + }) + break + } + + case 'MOCK_ACTIVATE': { + activeClientIds.add(clientId) + + sendToClient(client, { + type: 'MOCKING_ENABLED', + payload: { + client: { + id: client.id, + frameType: client.frameType, + }, + }, + }) + break + } + + case 'MOCK_DEACTIVATE': { + activeClientIds.delete(clientId) + break + } + + case 'CLIENT_CLOSED': { + activeClientIds.delete(clientId) + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId + }) + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister() + } + + break + } + } +}) + +self.addEventListener('fetch', function (event) { + const { request } = event + + // Bypass navigation requests. + if (request.mode === 'navigate') { + return + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') { + return + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been deleted (still remains active until the next reload). + if (activeClientIds.size === 0) { + return + } + + // Generate unique request ID. + const requestId = crypto.randomUUID() + event.respondWith(handleRequest(event, requestId)) +}) + +async function handleRequest(event, requestId) { + const client = await resolveMainClient(event) + const response = await getResponse(event, client, requestId) + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + ;(async function () { + const responseClone = response.clone() + + sendToClient( + client, + { + type: 'RESPONSE', + payload: { + requestId, + isMockedResponse: IS_MOCKED_RESPONSE in response, + type: responseClone.type, + status: responseClone.status, + statusText: responseClone.statusText, + body: responseClone.body, + headers: Object.fromEntries(responseClone.headers.entries()), + }, + }, + [responseClone.body], + ) + })() + } + + return response +} + +// Resolve the main client for the given event. +// Client that issues a request doesn't necessarily equal the client +// that registered the worker. It's with the latter the worker should +// communicate with during the response resolving phase. +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId) + + if (activeClientIds.has(event.clientId)) { + return client + } + + if (client?.frameType === 'top-level') { + return client + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === 'visible' + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id) + }) +} + +async function getResponse(event, client, requestId) { + const { request } = event + + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const requestClone = request.clone() + + function passthrough() { + // Cast the request headers to a new Headers instance + // so the headers can be manipulated with. + const headers = new Headers(requestClone.headers) + + // Remove the "accept" header value that marked this request as passthrough. + // This prevents request alteration and also keeps it compliant with the + // user-defined CORS policies. + const acceptHeader = headers.get('accept') + if (acceptHeader) { + const values = acceptHeader.split(',').map((value) => value.trim()) + const filteredValues = values.filter( + (value) => value !== 'msw/passthrough', + ) + + if (filteredValues.length > 0) { + headers.set('accept', filteredValues.join(', ')) + } else { + headers.delete('accept') + } + } + + return fetch(requestClone, { headers }) + } + + // Bypass mocking when the client is not active. + if (!client) { + return passthrough() + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return passthrough() + } + + // Notify the client that a request has been intercepted. + const requestBuffer = await request.arrayBuffer() + const clientMessage = await sendToClient( + client, + { + type: 'REQUEST', + payload: { + id: requestId, + url: request.url, + mode: request.mode, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: requestBuffer, + keepalive: request.keepalive, + }, + }, + [requestBuffer], + ) + + switch (clientMessage.type) { + case 'MOCK_RESPONSE': { + return respondWithMock(clientMessage.data) + } + + case 'PASSTHROUGH': { + return passthrough() + } + } + + return passthrough() +} + +function sendToClient(client, message, transferrables = []) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel() + + channel.port1.onmessage = (event) => { + if (event.data && event.data.error) { + return reject(event.data.error) + } + + resolve(event.data) + } + + client.postMessage( + message, + [channel.port2].concat(transferrables.filter(Boolean)), + ) + }) +} + +async function respondWithMock(response) { + // Setting response status code to 0 is a no-op. + // However, when responding with a "Response.error()", the produced Response + // instance will have status code set to 0. Since it's not possible to create + // a Response instance with status code 0, handle that use-case separately. + if (response.status === 0) { + return Response.error() + } + + const mockedResponse = new Response(response.body, response) + + Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { + value: true, + enumerable: true, + }) + + return mockedResponse +} diff --git a/packages/sandbox/src/elements/my-params-form.ts b/packages/sandbox/src/elements/my-params-form.ts new file mode 100644 index 0000000..1a052de --- /dev/null +++ b/packages/sandbox/src/elements/my-params-form.ts @@ -0,0 +1,105 @@ +import { LitElement, html } from 'lit' +import { customElement } from 'lit/decorators.js' +import { State } from '@heymp/signals'; + +/** + * An example element. + * + * @slot - This element has a slot + * @csspart button - The button + */ +@customElement('my-params-form') +export class MyParamsForm extends LitElement { + formData = new State(new FormData()); + + constructor() { + super(); + } + + connectedCallback(): void { + super.connectedCallback(); + this.init(); + } + + protected createRenderRoot(): HTMLElement | DocumentFragment { + return this; + } + + render() { + return html` +
+ Mocks +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+
+ ` + } + + init() { + for (const [key, value] of Array.from(this.getUrlParams())) { + this.formData.value.set(key, value); + } + } + + formReset(e: Event) { + if (e.type !== 'reset') return; + this.formData.value = new FormData(); + } + + formChanged() { + if (!this.formData) return; + const form = this.renderRoot?.querySelector('form'); + console.log(form); + if (!form) return; + this.formData.value = new FormData(form); + console.log([...this.formData.value]) + this.syncToURL(); + } + + syncToURL() { + const params = new URLSearchParams(); + for (const [key, value] of Array.from(this.formData.value)) { + if (key === 'users-count') { + if (this.formData.value.has('users')) { + params.set(key, value.toString()) + } + } + else { + params.set(key, value.toString()); + } + } + window.location.search = params.toString(); + } + + getUrlParams() { + return new URLSearchParams(window.location.search); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'my-params-form': MyParamsForm + } +} diff --git a/packages/sandbox/src/elements/my-user.ts b/packages/sandbox/src/elements/my-user.ts new file mode 100644 index 0000000..1ae04a9 --- /dev/null +++ b/packages/sandbox/src/elements/my-user.ts @@ -0,0 +1,62 @@ +import { watchSignal } from '@heymp/signals/lit'; +import { LitElement, html } from 'lit' +import { customElement } from 'lit/decorators.js' +import { match, P } from 'ts-pattern'; +import { UserSignal } from '../services/user.js'; + + +/** + * An example element. + * + * @slot - This element has a slot + * @csspart button - The button + */ +@customElement('my-user') +export class MyUser extends LitElement { + @watchSignal + user?: UserSignal; + + protected createRenderRoot(): HTMLElement | DocumentFragment { + return this; + } + + render() { + const name = `${this.user?.value?.firstName} ${this.user?.value?.lastName}`; + const ariaLabel = this.user?.value.isActive ? 'Activate user' : 'Deactivate user'; + + return html` + + `; + } + + renderIsIcon() { + const state = this.user?.state; + const isActive = this.user?.value.isActive; + return match([state, isActive]) + .with(['error', P._], () => '🚨') + .with(['deactivating', P._], () => '😴') + .with(['activating', P._], () => '😎') + .with([P._, true], () => '😎') + .with([P._, false], () => '😴') + .otherwise(() => '🔌') + } + + userClicked() { + console.log('clicked') + this.user?.toggleActivation(); + } + + isDisabled() { + return match(this.user?.state) + .with('activating', () => true) + .with('deactivating', () => true) + .with('updating', () => true) + .otherwise(() => false) + } +} + +declare global { + interface HTMLElementTagNameMap { + 'my-user': MyUser + } +} diff --git a/packages/sandbox/src/index.css b/packages/sandbox/src/index.css index a86963e..b0ad638 100644 --- a/packages/sandbox/src/index.css +++ b/packages/sandbox/src/index.css @@ -24,8 +24,6 @@ a:hover { body { margin: 0; - display: flex; - place-items: center; min-width: 320px; min-height: 100vh; } @@ -36,3 +34,14 @@ body { background-color: #ffffff; } } + +.user-list { + padding: 2.5rem; +} + +details { + padding: 1em; + border: 5px solid #f7f7f7; + border-radius: 3px; + margin: 1em; +} diff --git a/packages/sandbox/src/lib/fetchWrapper.ts b/packages/sandbox/src/lib/fetchWrapper.ts new file mode 100644 index 0000000..b25730b --- /dev/null +++ b/packages/sandbox/src/lib/fetchWrapper.ts @@ -0,0 +1,178 @@ +type FetchWrapperErrorType = 'STATUS_ERROR' | 'PARSE_ERROR' | 'NETWORK_ERROR'; + +type FetchWrapperErrorParams = Pick< + // eslint-disable-next-line no-use-before-define + FetchWrapperError, + 'ok' | 'type' | 'status' | 'statusText' | 'response' | 'error' | 'headers' +>; + +export class FetchWrapperError extends Error { + ok: boolean; + + type: FetchWrapperErrorType; + + status: number; + + statusText: string; + + response: Response | null; + + headers: Record | null; + + error: Error; + + constructor(args: FetchWrapperErrorParams) { + super(); + this.ok = args.ok; + this.type = args.type; + this.status = args.status; + this.statusText = args.statusText; + this.response = args.response; + this.headers = args.headers; + this.error = args.error; + this.message = args.type; + + // add the status error to the message + if (this.type === 'STATUS_ERROR') { + this.message += `: ${this.status}`; + } + } +} + +type FetchWrapperParams = { + url: string; + options?: RequestInit; + parser?: 'json' | 'text'; + maxRetryCount?: number; +}; + +type FetchWrapperResult = + | { + data: T; + error: null; + } + | { + data: null; + error: FetchWrapperError; + }; + +const fetchWithRetries = async ( + url: string, + options: RequestInit, + maxRetryCount: number, + retryCount = 0 +): Promise => { + try { + return await fetch(url, options ?? {}); + } catch (e) { + if (retryCount < maxRetryCount) { + return fetchWithRetries(url, options, maxRetryCount, retryCount + 1); + } + throw e; + } +}; + +const fetchOriginalResponse = ( + url: string, + options?: RequestInit, + maxRetryCount?: number +): Promise => { + if (maxRetryCount) { + return fetchWithRetries(url, options ?? {}, maxRetryCount); + } + + return fetch(url, options ?? {}); +}; + +/** + * Returns the results of fetch as values + * instead of throwing errors. + * + * @example + * const {data, error} = await fetchWrapper(fetch('https://jsonplaceholder.typicode.com/todos')); + * if (error) { + * if (error.status === '403') { + * console.log('You need to log in') + * } else { + * const {err} = error; + * err.name = 'Service Error: failed to fetch todos' + * err.message = error.type; + * throw err; + * } + * } + * const todos = data; + */ +export async function fetchWrapper( + args: FetchWrapperParams +): Promise> { + const { url, options, parser, maxRetryCount } = args; + let response: Response; + try { + const originalResponse = await fetchOriginalResponse( + url, + options, + maxRetryCount + ); + response = originalResponse.clone(); + + if (!response.ok) { + return { + data: null, + error: new FetchWrapperError({ + ok: response.ok, + status: response.status, + statusText: response.statusText, + response, + headers: Object.fromEntries(response.headers.entries()), + type: 'STATUS_ERROR', + error: new Error('STATUS_NOT_OK'), + }), + }; + } + + // Parse + try { + const data = (await originalResponse[parser ?? 'json']()) as T; + return { + data, + error: null, + }; + } catch (error) { + return { + data: null, + error: new FetchWrapperError({ + ok: response.ok, + status: response.status, + statusText: response.statusText, + type: 'PARSE_ERROR', + headers: Object.fromEntries(response.headers.entries()), + response, + error: error as Error, + }), + }; + } + } catch (error) { + return { + data: null, + error: new FetchWrapperError({ + ok: false, + status: 0, + statusText: 'Network Error', + response: null, + type: 'NETWORK_ERROR', + headers: null, + error: error as Error, + }), + }; + } +} + +export async function fetchWrapperTimer( + args: FetchWrapperParams, + timer: number +): Promise> { + // eslint-disable-next-line no-promise-executor-return + const delay = new Promise(res => setTimeout(res, timer)); + const [result] = await Promise.all([fetchWrapper(args), delay]); + return result; +} diff --git a/packages/sandbox/src/lib/random.ts b/packages/sandbox/src/lib/random.ts new file mode 100644 index 0000000..d84a15d --- /dev/null +++ b/packages/sandbox/src/lib/random.ts @@ -0,0 +1,20 @@ +export type RandomOptions = { + /** + * Integer between 0-1 + */ + threshold: number; +} + +/** + * Random + * + * @example random(0.2) + */ +export function random(options?: RandomOptions) { + const opt = { + threshold: 0.2, + ...options + } satisfies RandomOptions; + + return Math.random() > opt.threshold; +} diff --git a/packages/sandbox/src/mocks/browser.ts b/packages/sandbox/src/mocks/browser.ts new file mode 100644 index 0000000..86693f9 --- /dev/null +++ b/packages/sandbox/src/mocks/browser.ts @@ -0,0 +1,5 @@ +// src/mocks/browser.js +import { setupWorker } from 'msw/browser' +import { handlers } from './handlers.js' + +export const worker = setupWorker(...handlers) diff --git a/packages/sandbox/src/mocks/data/users.ts b/packages/sandbox/src/mocks/data/users.ts new file mode 100644 index 0000000..1877369 --- /dev/null +++ b/packages/sandbox/src/mocks/data/users.ts @@ -0,0 +1,13 @@ +import { User } from '../../types.js'; +import { faker } from '@faker-js/faker'; + +export function getUsers(amount: number) { + return Array(amount).fill(null).map(() => { + const id = faker.string.uuid(); + const firstName = faker.person.firstName(); + const lastName = faker.person.lastName(); + const isActive = faker.datatype.boolean(); + + return { id, firstName, lastName, isActive }; + }) satisfies User[]; +} diff --git a/packages/sandbox/src/mocks/handlers.ts b/packages/sandbox/src/mocks/handlers.ts new file mode 100644 index 0000000..f031a27 --- /dev/null +++ b/packages/sandbox/src/mocks/handlers.ts @@ -0,0 +1,44 @@ +import { http, delay, HttpResponse } from 'msw'; +import { random } from '../lib/random.js'; +import { getUsers } from './data/users.js'; +import * as ParamOptions from './queryParamOptions.js'; + +const USERS = getUsers(ParamOptions.userCount); + +export const handlers = [ + http.all('*', async () => { + await delay(ParamOptions.userDelay); + + if (random({ threshold: ParamOptions.randomError })) { + return HttpResponse.error(); + } + }), + + // Intercept "GET https://example.com/user" requests... + http.get('https://example.com/users', async () => { + return HttpResponse.json(USERS); + }), + + http.post('https://example.com/users/:id/activate', async ({ params }) => { + const { id } = params; + const user = USERS.find(i => i.id === id); + + if (user) { + user.isActive = true; + } + + return HttpResponse.json(user); + }), + + http.post('https://example.com/users/:id/deactivate', async ({ params }) => { + await delay(ParamOptions.userDelay); + const { id } = params; + const user = USERS.find(i => i.id === id); + + if (user) { + user.isActive = false; + } + + return HttpResponse.json(user); + }), +] diff --git a/packages/sandbox/src/mocks/init.ts b/packages/sandbox/src/mocks/init.ts new file mode 100644 index 0000000..e52c236 --- /dev/null +++ b/packages/sandbox/src/mocks/init.ts @@ -0,0 +1,13 @@ +export async function initMocks() { + const params = new URLSearchParams(window.location.search); + + if (!params.has('mocks')) { + return + } + + const { worker } = await import('./browser') + + // `worker.start()` returns a Promise that resolves + // once the Service Worker is up and ready to intercept requests. + return worker.start() +} diff --git a/packages/sandbox/src/mocks/queryParamOptions.ts b/packages/sandbox/src/mocks/queryParamOptions.ts new file mode 100644 index 0000000..4738d3f --- /dev/null +++ b/packages/sandbox/src/mocks/queryParamOptions.ts @@ -0,0 +1,21 @@ +const params = new URLSearchParams(window.location.search); + +export const userDelay = params.has('delay') + ? params.get('delay') !== 'on' + ? Number(params.get('delay')) + // undefined will result in a random + // amount of delay lengths + : undefined + : 0; + +export const userCount = params.has('users-count') + ? params.get('users-count') !== '' + ? Number(params.get('users-count')) + : 10 + : 10; + +export const randomError = params.has('random-error') + ? params.get('random-error') !== 'on' + ? Number(params.get('random-error')) + : .8 + : 1; diff --git a/packages/sandbox/src/my-doubler.ts b/packages/sandbox/src/my-doubler.ts deleted file mode 100644 index 224c86a..0000000 --- a/packages/sandbox/src/my-doubler.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { LitElement, html } from 'lit' -import { customElement } from 'lit/decorators.js' -import { State } from '@heymp/signals'; -import { watchSignal } from '@heymp/signals/lit'; - -/** - * An example element. - * - * @slot - This element has a slot - * @csspart button - The button - */ -@customElement('my-doubler') -export class MyDoubler extends LitElement { - @watchSignal - private count?: State; - - @watchSignal - private message = new State('hello, world'); - - render() { - return html` - ${this.message.value} ${this.count?.value} - ` - } -} - -declare global { - interface HTMLElementTagNameMap { - 'my-doubler': MyDoubler - } -} diff --git a/packages/sandbox/src/my-element.ts b/packages/sandbox/src/my-element.ts index 859b49a..f4d97aa 100644 --- a/packages/sandbox/src/my-element.ts +++ b/packages/sandbox/src/my-element.ts @@ -1,16 +1,17 @@ -import { LitElement, css, html } from 'lit' -import { customElement} from 'lit/decorators.js' -import { State, Computed } from '@heymp/signals'; -import litLogo from './assets/lit.svg' -import viteLogo from '/vite.svg' -import { store } from './store'; +import { LitElement, html } from 'lit' +import { customElement } from 'lit/decorators.js' +import { repeat } from 'lit/directives/repeat.js'; +import { createRef, ref } from 'lit/directives/ref.js'; import { watchSignal } from '@heymp/signals/lit'; -import './my-doubler'; -import './my-late-signal'; -import './my-form'; +import { match, P } from 'ts-pattern'; +import { UsersSignal } from './services/users.js'; +import { UserSignal } from './services/user.js'; +import { initMocks } from './mocks/init.js'; +import './elements/my-params-form.js'; +import './elements/my-user.js'; +import { State } from '@heymp/signals'; -// example of how we can interact with existing signals -store.counter.value = 1; +await initMocks(); /** * An example element. @@ -21,151 +22,178 @@ store.counter.value = 1; @customElement('my-element') export class MyElement extends LitElement { @watchSignal - private isDone = store.isDone; + users = new UsersSignal([]); @watchSignal - private counter = store.counter; + searchFilter = new State(''); - private nextValue = new Computed(() => store.counter.value + 1, [store.counter]); + @watchSignal + letterFilter = new State(''); @watchSignal - private myFormData = new State(new FormData()); + statusFilter = new State('all'); + + private formRef = createRef(); connectedCallback(): void { super.connectedCallback(); - setTimeout(() => { - const el = this.shadowRoot?.querySelector('my-late-signal'); - el?.remove(); - }, 5000); + this.users.update(); + } + + protected createRenderRoot() { + return this; } render() { - console.log(Array.from(this.myFormData.value)) + const content = match(this.users.state) + .with('initial', () => this.renderLoadingState()) + .with('updating', () => this.renderLoadingState()) + .with('complete', () => this.renderDefaultState()) + .with('error', () => this.renderErrorState()) + .exhaustive() + return html` -
- - - - - - + + ${this.renderUserListForm()} +
+
Service State: ${this.renderState()}
+
Service Children State: ${this.renderChildStatus()}
+ + ${content}
- -
- ${this.renderCounter(this.isDone.value)} -
- Current doubled value: -
-
- Future doubled value: -
-
- - -
${this.formatPreFormData(this.myFormData.value)}
- ` + `; } - formatPreFormData(formData: FormData) { - const output = []; - for (const [key, value] of formData) { - if (value instanceof File) { - output.push([key, { name: value.name }]); - } - else { - output.push([key, value]); - } - } - return JSON.stringify(output); + renderLoadingState() { + return html`fetching users...`; } - renderCounter(isDone: boolean) { - if (isDone) { - return html` -

Counter is done!

-

- Read the Lit documentation -

- ` - } + renderDefaultState() { + const users = this.getFilteredUserList() ?? []; + + users.sort((a, b) => { + return (a.value?.isActive === b.value?.isActive) ? 0 : a.value?.isActive ? -1 : 1; + }); + return html` - +
(${users.length})
+
    + ${repeat(users, (user) => user.value.id, this.renderUserListItem)} +
` } - static styles = css` - :host { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - text-align: center; - } - - .logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; - } - .logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); - } - .logo.lit:hover { - filter: drop-shadow(0 0 2em #325cffaa); - } - - .card { - padding: 2em; - } - - .read-the-docs { - color: #888; - } - - ::slotted(h1) { - font-size: 3.2em; - line-height: 1.1; - } - - a { - font-weight: 500; - color: #646cff; - text-decoration: inherit; - } - a:hover { - color: #535bf2; - } - - button { - border-radius: 8px; - border: 1px solid transparent; - padding: 0.6em 1.2em; - font-size: 1em; - font-weight: 500; - font-family: inherit; - background-color: #1a1a1a; - cursor: pointer; - transition: border-color 0.25s; - } - button:hover { - border-color: #646cff; - } - button:focus, - button:focus-visible { - outline: 4px auto -webkit-focus-ring-color; - } - - @media (prefers-color-scheme: light) { - a:hover { - color: #747bff; + renderErrorState() { + return html`oops there was an error 🫠`; + } + + renderUserListItem(user: UserSignal) { + return html`
  • ` + } + + renderState() { + return match(this.users.state) + .with('error', () => html`🚨`) + .with('complete', () => html`✅`) + .otherwise(() => html`✨`) + } + + renderChildStatus() { + return match(this.users.childStates.value) + .with(P.when(set => set.size === 0), () => html`🔌`) + .with(P.when(set => set.has('error')), () => html`🚨`) + .with(P.when(set => + (set.size === 1 || set.size === 2) && + Array.from(set).every(value => value === 'complete' || value === 'initial') + ), () => html`✅`) + .otherwise(() => html`✨`) + } + + renderUserListForm() { + return html` +
    + Filter +
    +
    + Search filterFilter + +
    +
    + Filter by starting letter of last name + + + + ${'abcdefghijklmnopqrstuvwxyz'.split('').map(letter => html` + + + + `)} +
    +
    + Filter by user status + + + + + + + + + + + + +
    +
    +
    + `; + } + + + formUpdated(e: Event) { + e.preventDefault(); + + if (!this.formRef.value) { return; } + + const formData = new FormData(this.formRef.value); + + this.letterFilter.value = formData.get('letter-filter') as typeof this.letterFilter.value; + this.searchFilter.value = formData.get('search-filter') as typeof this.searchFilter.value; + this.statusFilter.value = formData.get('status-filter') as typeof this.statusFilter.value; + } + + getFilteredUserList() { + const letter = this.letterFilter.value.toLowerCase(); + const search = this.searchFilter.value.toLowerCase(); + const status = this.statusFilter.value; + + return this.users.value?.filter(user => { + if (!user.value.lastName.toLowerCase().startsWith(letter)) { + return false; } - button { - background-color: #f9f9f9; + + if (!`${user.value.firstName}${user.value.lastName}`.toLowerCase().includes(search)) { + return false; + } + + if (status !== "all") { + const userStatus = user.state === 'error' ? 'error' : user.value.isActive.toString(); + if (status !== userStatus) { + return false; + } } - } - ` + + return true; + }); + } + + /** + * Refresh the user list + */ + public refresh() { + this.users.update(); + } } declare global { diff --git a/packages/sandbox/src/my-form.ts b/packages/sandbox/src/my-form.ts deleted file mode 100644 index 4b730b8..0000000 --- a/packages/sandbox/src/my-form.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { LitElement, html } from 'lit' -import { customElement } from 'lit/decorators.js' -import { State } from '@heymp/signals'; - -/** - * An example element. - * - * @slot - This element has a slot - * @csspart button - The button - */ -@customElement('my-form') -export class MyForm extends LitElement { - formData = new State(new FormData()); - - render() { - return html` -
    - - - - - - - -
    - ` - } - - formReset(e: Event) { - if (e.type !== 'reset') return; - this.formData.value = new FormData(); - } - - formChanged() { - if (!this.formData) return; - const form = this.shadowRoot?.querySelector('form'); - if (!form) return; - this.formData.value = new FormData(form); - } -} - -declare global { - interface HTMLElementTagNameMap { - 'my-form': MyForm - } -} diff --git a/packages/sandbox/src/my-late-signal.ts b/packages/sandbox/src/my-late-signal.ts deleted file mode 100644 index 5de52b0..0000000 --- a/packages/sandbox/src/my-late-signal.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { LitElement, html } from 'lit' -import { customElement } from 'lit/decorators.js' -import { State } from '@heymp/signals'; -import { watchSignal } from '@heymp/signals/lit'; - -/** - * An example element. - * - * @slot - This element has a slot - * @csspart button - The button - */ -@customElement('my-late-signal') -export class MyLateSignal extends LitElement { - @watchSignal - private count?: State; - - private interval?: NodeJS.Timeout; - - connectedCallback(): void { - super.connectedCallback(); - this.count = new State(0); - setTimeout(() => { - // @ts-ignore - this.interval = setInterval(() => this.count.value++, 1000); - }, 1000); - } - - disconnectedCallback(): void { - super.disconnectedCallback(); - clearInterval(this.interval); - } - - render() { - return html` - ${this.count?.value} - ` - } -} - -declare global { - interface HTMLElementTagNameMap { - 'my-late-signal': MyLateSignal - } -} diff --git a/packages/sandbox/src/services/user.ts b/packages/sandbox/src/services/user.ts new file mode 100644 index 0000000..e41b233 --- /dev/null +++ b/packages/sandbox/src/services/user.ts @@ -0,0 +1,89 @@ +import { State, SignalUpdatedEvent } from '@heymp/signals'; +import { User } from '../types.js'; +import { fetchWrapper } from '../lib/fetchWrapper.js'; + +export const userSignalStates = ['initial', 'updating', 'activating', 'deactivating', 'complete', 'error'] as const; + +export type UserSignalState = typeof userSignalStates[number]; + +export class UserSignal extends State { + constructor(value: User) { + super(value); + } + + #state: UserSignalState = 'initial'; + + error?: Error; + + get state() { + return this.#state; + } + + set state(state: UserSignalState) { + const prev = this.#state; + if (prev !== state) { + this.#state = state; + this.dispatchEvent(new SignalUpdatedEvent(prev, this.#state)); + } + } + + async update(id: User['id']): Promise { + return fetch(`https://example.com/users/${id}`) + .then(res => { + if (!res.ok) { + const error = new Error(`[getUser] status not ok: ${res.status}`); + error.cause = res; + throw error; + } + return res; + }) + .then(res => res.json()) + } + + async activate() { + this.state = 'activating'; + const { data, error } = await fetchWrapper({ + url: `https://example.com/users/${this.value?.id}/activate`, + options: { method: 'POST' } + }); + + if (error) { + this.state = 'error'; + return { error }; + } + + this.value = { + ...data + }; + this.state = 'complete'; + + return { data }; + } + + async deactivate() { + this.state = 'deactivating'; + const { data, error } = await fetchWrapper({ + url: `https://example.com/users/${this.value?.id}/deactivate`, + options: { method: 'POST' } + }); + + if (error) { + this.state = 'error'; + return { error }; + } + + this.value = { + ...data + }; + this.state = 'complete'; + + return { data }; + } + + async toggleActivation() { + if (this.value.isActive) { + return this.deactivate(); + } + return this.activate(); + } +} diff --git a/packages/sandbox/src/services/users.ts b/packages/sandbox/src/services/users.ts new file mode 100644 index 0000000..9b5ab41 --- /dev/null +++ b/packages/sandbox/src/services/users.ts @@ -0,0 +1,50 @@ +import { SignalUpdatedEvent, Computed, List } from '@heymp/signals'; +import { User } from '../types.js'; +import { fetchWrapper } from '../lib/fetchWrapper.js'; +import { UserSignal } from './user.js'; + +export const usersSignalStates = ['initial', 'updating', 'complete', 'error'] as const; + +export type UsersSignalState = typeof usersSignalStates[number]; + +export class UsersSignal extends List { + constructor(value: UserSignal[]) { + super(value); + } + + childStates = new Computed(() => { + return new Set(this.value?.map(i => i.state)); + }, [this]); + + error?: Error; + + #state: UsersSignalState = 'initial'; + + get state() { + return this.#state; + } + + set state(state: UsersSignalState) { + const prev = this.#state; + if (prev !== state) { + this.#state = state; + this.dispatchEvent(new SignalUpdatedEvent(prev, this.#state)); + } + } + + async update() { + this.state = 'updating'; + const { data, error } = await fetchWrapper({ url: 'https://example.com/users' }); + + if (error) { + this.state = 'error'; + return { error }; + } + + this.value = data.map(i => new UserSignal(i)); + + this.state = 'complete'; + + return { data }; + } +} diff --git a/packages/sandbox/src/store.ts b/packages/sandbox/src/store.ts deleted file mode 100644 index 92f0371..0000000 --- a/packages/sandbox/src/store.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Signal } from '@heymp/signals'; - -class Counter extends Signal.State { - constructor(value: number) { - super(value); - } - - increment() { - if (this.value < 10) { - this.value++; - } - } - - decrement() { - if (this.value > 0) { - this.value--; - } - } -} - -const counter = new Counter(0); -const name = new Signal.State('World'); -const isDone = new Signal.Computed(() => counter.value > 9, [counter]); - -export const store = { - counter, - isDone, - name, -} diff --git a/packages/sandbox/src/types.ts b/packages/sandbox/src/types.ts new file mode 100644 index 0000000..e6fabd0 --- /dev/null +++ b/packages/sandbox/src/types.ts @@ -0,0 +1,6 @@ +export interface User { + id: string, + firstName: string, + lastName: string, + isActive: boolean, +} diff --git a/packages/sandbox/tsconfig.json b/packages/sandbox/tsconfig.json index 69e31ac..f26f841 100644 --- a/packages/sandbox/tsconfig.json +++ b/packages/sandbox/tsconfig.json @@ -1,24 +1,25 @@ { "compilerOptions": { - "target": "ES2020", + "target": "ES2022", "experimentalDecorators": true, "useDefineForClassFields": false, "module": "ESNext", - "lib": ["ES2020", "DOM", "DOM.Iterable"], + "lib": ["ES2022", "DOM", "DOM.Iterable"], "skipLibCheck": true, /* Bundler mode */ - "moduleResolution": "bundler", + "moduleResolution": "Bundler", "allowImportingTsExtensions": true, - "resolveJsonModule": true, "isolatedModules": true, + "moduleDetection": "force", "noEmit": true, /* Linting */ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true }, "include": ["src"] } diff --git a/packages/sandbox/vite.config.ts b/packages/sandbox/vite.config.ts new file mode 100644 index 0000000..526f4f3 --- /dev/null +++ b/packages/sandbox/vite.config.ts @@ -0,0 +1,7 @@ +import type { UserConfig } from 'vite' + +export default { + build: { + target: 'es2022' + } +} satisfies UserConfig From a733776dd2c6b47bf5a44c7f65ac05569873861f Mon Sep 17 00:00:00 2001 From: Michael Potter Date: Mon, 23 Dec 2024 10:24:36 -0500 Subject: [PATCH 04/13] feat: cleanup List children promises --- packages/signals/src/index.ts | 77 ++++++++++++++++------------------- 1 file changed, 36 insertions(+), 41 deletions(-) diff --git a/packages/signals/src/index.ts b/packages/signals/src/index.ts index 0624c89..d0fec6c 100644 --- a/packages/signals/src/index.ts +++ b/packages/signals/src/index.ts @@ -9,7 +9,7 @@ export class SignalUpdatedEvent extends Event { export class State extends EventTarget { _value: T; - _abortCtrl = new AbortController(); + _ac = new AbortController(); /** * Creates a new signal @@ -42,10 +42,18 @@ export class State extends EventTarget { * for await (const value of counter.stream()) { * } */ - async *stream() { - yield this.value; - while (true) { - yield new Promise(resolve => this.addEventListener('updated', () => resolve(this.value), { once: true, signal: this._abortCtrl.signal })); + async *stream(options?: { immediate: boolean }) { + if (!options?.immediate === false) { + yield this.value; + } + while (!this._ac.signal.aborted) { + yield new Promise((resolve) => { + this._ac.signal.onabort = () => { + console.log('disconnecting') + resolve(this.value); + } + this.addEventListener('updated', () => resolve(this.value), { once: true, signal: this._ac.signal }) + }); } } @@ -60,7 +68,8 @@ export class State extends EventTarget { } disconnect() { - this._abortCtrl.abort(); + this._ac.abort(); + this._ac = new AbortController(); } } @@ -85,56 +94,42 @@ export class Computed any, P extends State[]> e } export class List> extends State { + _acChildren?: AbortController; + + constructor(value: T[]) { + super(value); + this._watchChildren(); + } + override get value() { return super.value; } override set value(value: T[]) { if (this._value === value) return; - this._value.forEach(i => i.disconnect()); this._value = value; this.dispatchEvent(new Event('updated')); + this.disconnectChildren(); + this._watchChildren(); } - override async *stream() { - yield this.value; - while (true) { - const targets = [this, ...this.value]; - const ac = new AbortController(); - this._abortCtrl.signal.onabort = () => { - ac.abort(); + async _watchChildren() { + this.value.forEach(async value => { + for await (const _ of value.stream({ immediate: false })) { + if (this._acChildren?.signal.aborted) return; + this.dispatchEvent(new Event('updated')); } - await Promise.race(targets.map(target => - new Promise(resolve => target.addEventListener('updated', () => { - resolve() - }, { once: true, signal: ac.signal })) - )); - ac.abort(); - yield this.value; - } + }); } - // override async *stream() { - // yield this.value; - // while (true) { - // const targets = [this, ...this.value]; - // const ac = new AbortController(); - // this._abortCtrl.signal.onabort = () => { - // ac.abort(); - // } - // await Promise.race(targets.map(target => - // new Promise(resolve => target.addEventListener('updated', () => { - // resolve() - // }, { once: true, signal: ac.signal })) - // )); - // ac.abort(); - // yield this.value; - // } - // } - override disconnect(): void { - this.value.forEach(i => i.disconnect()); super.disconnect(); + this.disconnectChildren(); + } + + disconnectChildren(): void { + this._acChildren?.abort(); + this._acChildren = new AbortController(); } } From 93142272e48370012e63da8aebc758908e7a8474 Mon Sep 17 00:00:00 2001 From: Michael Potter Date: Mon, 23 Dec 2024 10:32:06 -0500 Subject: [PATCH 05/13] feat: disconnect List children automatically --- packages/signals/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/signals/src/index.ts b/packages/signals/src/index.ts index d0fec6c..d2a2d7f 100644 --- a/packages/signals/src/index.ts +++ b/packages/signals/src/index.ts @@ -128,6 +128,7 @@ export class List> extends State { } disconnectChildren(): void { + this.value.forEach(i => i.disconnect()); this._acChildren?.abort(); this._acChildren = new AbortController(); } From 9b4b16e21c377ce7c009d5616782d0dd7a9f8210 Mon Sep 17 00:00:00 2001 From: Michael Potter Date: Mon, 23 Dec 2024 10:37:05 -0500 Subject: [PATCH 06/13] chore: cleanp and docs --- packages/signals/src/index.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/signals/src/index.ts b/packages/signals/src/index.ts index d2a2d7f..26fb3a6 100644 --- a/packages/signals/src/index.ts +++ b/packages/signals/src/index.ts @@ -49,7 +49,6 @@ export class State extends EventTarget { while (!this._ac.signal.aborted) { yield new Promise((resolve) => { this._ac.signal.onabort = () => { - console.log('disconnecting') resolve(this.value); } this.addEventListener('updated', () => resolve(this.value), { once: true, signal: this._ac.signal }) @@ -96,6 +95,16 @@ export class Computed any, P extends State[]> e export class List> extends State { _acChildren?: AbortController; + /** + * Creates a reactive array of signals. + * Iterator updates when child values have updated. + * + * @param { T[] } value Initial value of arrray of Signals. + * @example + * const counter1 = new Signal.State(0) + * const counter2 = new Signal.State(0) + * const counters = new Signal.List([ counter1, counter2 ]) + */ constructor(value: T[]) { super(value); this._watchChildren(); From db58d148b3fc4741be096babd1b2d78f3dae7b8c Mon Sep 17 00:00:00 2001 From: Michael Potter Date: Mon, 23 Dec 2024 10:38:51 -0500 Subject: [PATCH 07/13] chore: cleanup --- packages/signals/src/index.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/signals/src/index.ts b/packages/signals/src/index.ts index 26fb3a6..f0039ee 100644 --- a/packages/signals/src/index.ts +++ b/packages/signals/src/index.ts @@ -1,5 +1,3 @@ -console.log('hi') - export class SignalUpdatedEvent extends Event { constructor(public oldValue: T, public newValue: T) { super('updated'); From 71f1a565665960fad001c8b5b4e15a1cbe38657c Mon Sep 17 00:00:00 2001 From: Michael Potter Date: Mon, 23 Dec 2024 10:40:40 -0500 Subject: [PATCH 08/13] chore: cleanup --- packages/signals/src/index-old.ts | 139 ------------------------------ 1 file changed, 139 deletions(-) delete mode 100644 packages/signals/src/index-old.ts diff --git a/packages/signals/src/index-old.ts b/packages/signals/src/index-old.ts deleted file mode 100644 index 740d7ee..0000000 --- a/packages/signals/src/index-old.ts +++ /dev/null @@ -1,139 +0,0 @@ -export class SignalUpdatedEvent extends Event { - constructor(public oldValue: T, public newValue: T) { - super('updated'); - } -} - -export class State extends EventTarget { - _value: T; - - /** - * Creates a new signal - * - * @param { T } value Initial value of the Signal - * @example - * const counter = new Signal.State(0) - */ - constructor(value: T) { - super(); - this._value = value; - } - - get value() { - return this._value; - } - - set value(value: T) { - const prevValue = this._value; - // dirty check - if (prevValue !== value) { - this._value = value; - this.dispatchEvent(new SignalUpdatedEvent(prevValue, this._value)); - } - } - - /** - * Async generator that yields the current value of the Signal and waits for the next update - * @example - * for await (const value of counter.stream()) { - * } - */ - async *stream() { - yield this.value; - while (true) { - yield new Promise(resolve => this.addEventListener('updated', () => resolve(this.value), { once: true })); - } - } - - /** - * Async iterator that yields the current value - * @example - * for await (const value of counter) { - * } - */ - [Symbol.asyncIterator]() { - return this.stream(); - } -} - -export class Computed any, P extends State[]> extends State> { - /** - * Computed Signal - * @param { Function } fn Function that computes the value of the Signal - * @param { State[] } props An array of dependencies that are instances of either Signal.State or Signal.Computed - * @example - * const isDone = Signal.Computed(() => counter.value > 9, [counter]) - */ - constructor(private fn: F, props: P) { - super(fn()); - props.forEach(prop => this.watcher(prop)); - } - - async watcher(prop: State) { - for await (const _ of prop) { - this.value = this.fn(); - } - } -} - -export class List> extends State { - override get value() { - return super.value; - } - - override set value(value: T[]) { - if (this._value === value) return; - this._value = value; - this.dispatchEvent(new Event('updated')); - } - - // add(signal: T) { - // this.value.push(signal); - // signal.addEventListener('updated', () => this.dispatchEvent(new Event('updated'))); - // this.dispatchEvent(new Event('updated')); - // } - - // remove(signal: T) { - // const index = this.value.indexOf(signal); - // if (index > -1) { - // this.value.splice(index, 1); - // signal.removeEventListener('updated', () => this.dispatchEvent(new Event('updated'))); - // this.dispatchEvent(new Event('updated')); - // } - // } - - override async *stream() { - yield this.value; - while (true) { - const targets = [this, ...this.value]; - const ac = new AbortController(); - await Promise.race(targets.map(target => - new Promise(resolve => target.addEventListener('updated', () => resolve(), { once: true, signal: ac.signal })) - )); - ac.abort(); - yield this.value; - } - } - // override async *stream() { - // yield this.value; - // while (true) { - // const targets = [this, ...this.value]; - // await Promise.race(targets.map(t => - // new Promise(resolve => t.addEventListener('updated', () => resolve(), { once: true })) - // )); - // yield this.value; - // } - // } -} - -/** - * Signal object that contains State and Computed classes - * @example - * const counter = Signal.State(0) - * const isDone = Signal.Computed(() => counter.value > 9, [counter]) - */ -export class Signal { - static State = State; - static Computed = Computed; - static List = List; -} From 6f35143a099fea3f83018a540be4b08bff52d302 Mon Sep 17 00:00:00 2001 From: Michael Potter Date: Tue, 24 Dec 2024 09:28:18 -0500 Subject: [PATCH 09/13] refactor: swap async iterators with event listeners internally --- packages/signals/src/index.ts | 28 +++++++++++++--------------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/packages/signals/src/index.ts b/packages/signals/src/index.ts index f0039ee..898974d 100644 --- a/packages/signals/src/index.ts +++ b/packages/signals/src/index.ts @@ -41,7 +41,7 @@ export class State extends EventTarget { * } */ async *stream(options?: { immediate: boolean }) { - if (!options?.immediate === false) { + if (options?.immediate !== false) { yield this.value; } while (!this._ac.signal.aborted) { @@ -80,13 +80,11 @@ export class Computed any, P extends State[]> e */ constructor(private fn: F, props: P) { super(fn()); - props.forEach(prop => this.watcher(prop)); - } - - async watcher(prop: State) { - for await (const _ of prop) { - this.value = this.fn(); - } + props.forEach(prop => + prop.addEventListener('updated', () => { + this.value = this.fn(); + }, { signal: this._ac.signal }) + ); } } @@ -113,19 +111,19 @@ export class List> extends State { } override set value(value: T[]) { - if (this._value === value) return; + const prevValue = this._value; + if (prevValue === value) return; this._value = value; - this.dispatchEvent(new Event('updated')); + this.dispatchEvent(new SignalUpdatedEvent(prevValue, this._value)); this.disconnectChildren(); this._watchChildren(); } - async _watchChildren() { - this.value.forEach(async value => { - for await (const _ of value.stream({ immediate: false })) { - if (this._acChildren?.signal.aborted) return; + _watchChildren() { + this.value.forEach(value => { + value.addEventListener('updated', () => { this.dispatchEvent(new Event('updated')); - } + }, { signal: this._acChildren?.signal }) }); } From 060170fe47e28fd56b0854e2132ac9913abd61ad Mon Sep 17 00:00:00 2001 From: Michael Potter Date: Tue, 24 Dec 2024 11:00:55 -0500 Subject: [PATCH 10/13] chore: comments --- packages/signals/src/index.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/signals/src/index.ts b/packages/signals/src/index.ts index 898974d..3bbc4ae 100644 --- a/packages/signals/src/index.ts +++ b/packages/signals/src/index.ts @@ -7,6 +7,10 @@ export class SignalUpdatedEvent extends Event { export class State extends EventTarget { _value: T; + /** + * Abort Controller used to cleanup + * event listeners in AsyncIterator stream. + */ _ac = new AbortController(); /** @@ -64,6 +68,9 @@ export class State extends EventTarget { return this.stream(); } + /** + * Cancel event listeners in AsyncIterator stream. + */ disconnect() { this._ac.abort(); this._ac = new AbortController(); From 2e7c524a545ff1ee0a9bf1b4e83fd4720fae8dc0 Mon Sep 17 00:00:00 2001 From: Michael Potter Date: Tue, 24 Dec 2024 12:40:00 -0500 Subject: [PATCH 11/13] fix: cleanup State iterator on cleanup --- packages/signals/src/index.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/signals/src/index.ts b/packages/signals/src/index.ts index 3bbc4ae..e556e36 100644 --- a/packages/signals/src/index.ts +++ b/packages/signals/src/index.ts @@ -49,12 +49,20 @@ export class State extends EventTarget { yield this.value; } while (!this._ac.signal.aborted) { - yield new Promise((resolve) => { + let done = false; + await new Promise((resolve) => { + this.addEventListener('updated', () => resolve(this.value), { once: true, signal: this._ac.signal }) this._ac.signal.onabort = () => { + done = true; resolve(this.value); } - this.addEventListener('updated', () => resolve(this.value), { once: true, signal: this._ac.signal }) }); + if (!done) { + yield this.value; + } + else { + return + } } } From 56043bb61becd10985141a59962e615661bcba7a Mon Sep 17 00:00:00 2001 From: Michael Potter Date: Thu, 26 Dec 2024 12:11:36 -0500 Subject: [PATCH 12/13] feat: add length property to List --- packages/signals/src/index.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/signals/src/index.ts b/packages/signals/src/index.ts index e556e36..fead892 100644 --- a/packages/signals/src/index.ts +++ b/packages/signals/src/index.ts @@ -134,6 +134,10 @@ export class List> extends State { this._watchChildren(); } + get length() { + return this.value.length; + } + _watchChildren() { this.value.forEach(value => { value.addEventListener('updated', () => { From a406f41baf91631c2e81c93beb89b9100490b45d Mon Sep 17 00:00:00 2001 From: Michael Potter Date: Thu, 26 Dec 2024 12:12:44 -0500 Subject: [PATCH 13/13] chore: add vitest --- packages/signals/package.json | 4 +- packages/signals/src/index.spec.ts | 105 +++++++++++++++++++++++++++++ packages/signals/tsconfig.json | 3 +- 3 files changed, 110 insertions(+), 2 deletions(-) create mode 100644 packages/signals/src/index.spec.ts diff --git a/packages/signals/package.json b/packages/signals/package.json index e780efd..7546518 100644 --- a/packages/signals/package.json +++ b/packages/signals/package.json @@ -32,6 +32,7 @@ ], "scripts": { "dev": "tsc --watch", + "test": "vitest", "build": "tsc", "prepack": "wireit", "postpack": "wireit" @@ -49,7 +50,8 @@ }, "devDependencies": { "tslib": "^2.6.2", - "typescript": "^5.2.2" + "typescript": "^5.2.2", + "vitest": "^2.1.8" }, "peerDependencies": { "@types/react": "^18.2.23", diff --git a/packages/signals/src/index.spec.ts b/packages/signals/src/index.spec.ts new file mode 100644 index 0000000..164019e --- /dev/null +++ b/packages/signals/src/index.spec.ts @@ -0,0 +1,105 @@ +// sum.test.js +import { expect, test } from 'vitest' +import { Signal } from './index.js'; + +/** + * STATE + */ +test('State emits update event', () => { + const counter = new Signal.State(0); + counter.addEventListener('updated', () => { + expect(counter.value).toBe(1); + }) + counter.value = 1; +}); + +test('State contains async itertator', async () => { + const counter = new Signal.State(0); + const listener = async () => { + for await (const count of counter) { + if (count === 1) { + return count; + } + } + return undefined; + } + counter.value = 1; + const count = await listener(); + expect(count).toBe(1); +}); + +/** + * COMPUTED + */ +test('Computed contains async itertator', async () => { + const counter = new Signal.State(0); + const listener = async () => { + for await (const count of counter) { + if (count === 1) { + return count; + } + } + return undefined; + } + counter.value = 1; + const count = await listener(); + expect(count).toBe(1); +}); + +test('Computed emits one event', async () => { + const counter = new Signal.State(0); + let events = 0; + const listener = async () => { + for await (const count of counter) { + events++; + if (count === 1) { + return count; + } + } + return undefined; + } + counter.value = 1; + await listener(); + expect(events).toBe(1); +}); + +/** + * LIST + */ +test('List should contain a length property', async () => { + const counters = new Signal.List([new Signal.State(0), new Signal.State(0)]); + expect(counters.length).toBe(2); +}); + +test('List emits one event per change', async () => { + const counters = new Signal.List([new Signal.State(0), new Signal.State(0)]); + let events = 0; + + counters.addEventListener('updated', () => { + events++; + }); + + // incrementing counter triggers event + counters.value.at(0)!.value = 1 + expect(events).toBe(1); + + // setting counter to the same value + // should not trigger an additional event + counters.value.at(0)!.value = 1; + expect(events).toBe(1); + + // incrementing counter to a different value + // should trigger change + counters.value.at(0)!.value++ + expect(events).toBe(2); +}); + +// test('State can be cleaned up', async () => { +// let counters = new Signal.List([new Signal.State(0)]); +// for (let i = 0; i < 1000; i++) { +// const value = counters.value; +// counters = new Signal.List([...value, new Signal.State(0)]); +// } +// counters.disconnect(); +// await new Promise(res => setTimeout(res, 5000)); +// }); diff --git a/packages/signals/tsconfig.json b/packages/signals/tsconfig.json index 9590772..241cded 100644 --- a/packages/signals/tsconfig.json +++ b/packages/signals/tsconfig.json @@ -22,5 +22,6 @@ "noImplicitOverride": true, "types": ["node"] }, - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.spec.ts"] }