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 diff --git a/packages/signals/package.json b/packages/signals/package.json index 613a46f..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,10 +50,14 @@ }, "devDependencies": { "tslib": "^2.6.2", - "typescript": "^5.2.2" + "typescript": "^5.2.2", + "vitest": "^2.1.8" }, "peerDependencies": { "@types/react": "^18.2.23", "react": "^18.2.0" + }, + "dependencies": { + "msw": "^2.7.0" } } 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/src/index.ts b/packages/signals/src/index.ts index e1a1ed0..fead892 100644 --- a/packages/signals/src/index.ts +++ b/packages/signals/src/index.ts @@ -7,6 +7,12 @@ 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(); + /** * Creates a new signal * @@ -38,10 +44,25 @@ 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 })); + async *stream(options?: { immediate: boolean }) { + if (options?.immediate !== false) { + yield this.value; + } + while (!this._ac.signal.aborted) { + 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); + } + }); + if (!done) { + yield this.value; + } + else { + return + } } } @@ -54,6 +75,14 @@ export class State extends EventTarget { [Symbol.asyncIterator]() { return this.stream(); } + + /** + * Cancel event listeners in AsyncIterator stream. + */ + disconnect() { + this._ac.abort(); + this._ac = new AbortController(); + } } export class Computed any, P extends State[]> extends State> { @@ -66,13 +95,66 @@ export class Computed any, P extends State[]> e */ constructor(private fn: F, props: P) { super(fn()); - props.forEach(prop => this.watcher(prop)); + props.forEach(prop => + prop.addEventListener('updated', () => { + this.value = this.fn(); + }, { signal: this._ac.signal }) + ); } +} - async watcher(prop: State) { - for await (const _ of prop) { - this.value = this.fn(); - } +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(); + } + + override get value() { + return super.value; + } + + override set value(value: T[]) { + const prevValue = this._value; + if (prevValue === value) return; + this._value = value; + this.dispatchEvent(new SignalUpdatedEvent(prevValue, this._value)); + this.disconnectChildren(); + this._watchChildren(); + } + + get length() { + return this.value.length; + } + + _watchChildren() { + this.value.forEach(value => { + value.addEventListener('updated', () => { + this.dispatchEvent(new Event('updated')); + }, { signal: this._acChildren?.signal }) + }); + } + + override disconnect(): void { + super.disconnect(); + this.disconnectChildren(); + } + + disconnectChildren(): void { + this.value.forEach(i => i.disconnect()); + this._acChildren?.abort(); + this._acChildren = new AbortController(); } } @@ -85,4 +167,5 @@ export class Computed any, P extends State[]> e export class Signal { static State = State; static Computed = Computed; + static List = List; } 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"] }