UNPKG

@furystack/shades

Version:

A lightweight UI framework for FuryStack with JSX support

358 lines 16.5 kB
import { Injector } from '@furystack/inject'; import { ObservableValue } from '@furystack/utils'; import { LocationService } from './services/location-service.js'; import { ResourceManager } from './services/resource-manager.js'; import { attachProps, attachStyles, setRenderMode } from './shade-component.js'; import { StyleManager } from './style-manager.js'; import { patchChildren, toVChildArray } from './vnode.js'; /** * Defines and registers a Shade component as a custom element. Returns a * JSX-callable factory `(props, children?) => JSX.Element`. Throws when a * component with the same {@link ShadeOptions.customElementName} has * already been registered — registration is global and process-wide. * * The returned factory is the entry point for downstream code. The custom * element class itself is installed via `customElements.define` and never * directly exposed. */ export const Shade = (o) => { const { customElementName } = o; const existing = customElements.get(customElementName); if (!existing) { // Register CSS styles if provided if (o.css) { StyleManager.registerComponentStyles(customElementName, o.css, o.elementBaseName); } const ElementBase = o.elementBase || HTMLElement; customElements.define(customElementName, class extends ElementBase { _renderCount = 0; getRenderCount() { return this._renderCount; } resourceManager = new ResourceManager(); /** * Host props collected during the current render pass via `useHostProps`. * Applied to the host element after each render. */ _pendingHostProps = []; /** * The host props that were applied in the previous render, used for diffing. */ _prevHostProps = null; /** * Cached ref objects keyed by the user-provided key string. */ _refs = new Map(); /** * Set to true once disconnectedCallback fires. Prevents ghost re-renders * triggered by observable changes during async disposal. */ _disconnected = false; connectedCallback() { this._disconnected = false; this._performUpdate(); } async disconnectedCallback() { this._disconnected = true; this._refs.clear(); this._prevVTree = null; this._prevHostProps = null; await this.resourceManager[Symbol.asyncDispose](); } props; shadeChildren; render = (options) => { this._renderCount++; return o.render(options); }; getRenderOptions = () => { const renderOptions = { props: this.props, injector: this.injector, children: this.shadeChildren, renderCount: this._renderCount, useHostProps: (hostProps) => { this._pendingHostProps.push(hostProps); }, useRef: (key) => { const existingRef = this._refs.get(key); if (existingRef) return existingRef; const refObject = { current: null }; this._refs.set(key, refObject); return refObject; }, useObservable: (key, observable, options) => { const onChange = options?.onChange || (() => this.updateComponent()); return this.resourceManager.useObservable(key, observable, onChange, options); }, useState: (key, initialValue) => this.resourceManager.useState(key, initialValue, this.updateComponent.bind(this)), useSearchState: (key, initialValue) => this.resourceManager.useObservable(`useSearchState-${key}`, this.injector.get(LocationService).useSearchParam(key, initialValue), () => this.updateComponent()), useStoredState: (key, initialValue, storageArea = localStorage) => { const getFromStorage = () => { const value = storageArea?.getItem(key); return value ? JSON.parse(value) : initialValue; }; const setToStorage = (value) => { if (JSON.stringify(value) !== storageArea?.getItem(key)) { const newValue = JSON.stringify(value); storageArea?.setItem(key, newValue); } if (JSON.stringify(observable.getValue()) !== JSON.stringify(value)) { observable.setValue(value); } }; const observable = this.resourceManager.useDisposable(`useStoredState-${key}`, () => new ObservableValue(getFromStorage())); const updateFromStorageEvent = (e) => { if (e.key === key && e.storageArea === storageArea) { setToStorage((e.newValue && JSON.parse(e.newValue)) || initialValue); } }; this.resourceManager.useDisposable(`useStoredState-${key}-storage-event`, () => { window.addEventListener('storage', updateFromStorageEvent); const channelName = `useStoredState-broadcast-channel`; const messageChannel = new BroadcastChannel(channelName); messageChannel.onmessage = (e) => { if (e.data.key === key) { setToStorage(e.data.value); } }; const subscription = observable.subscribe((value) => { messageChannel.postMessage({ key, value }); }); return { [Symbol.dispose]: () => { window.removeEventListener('storage', updateFromStorageEvent); subscription[Symbol.dispose](); messageChannel.close(); }, }; }); observable.subscribe(setToStorage); return this.resourceManager.useObservable(`useStoredState-${key}`, observable, () => this.updateComponent()); }, useDisposable: this.resourceManager.useDisposable.bind(this.resourceManager), }; return renderOptions; }; _updateScheduled = false; /** * The VChild array from the previous render, with `_el` references * pointing to the real DOM nodes. Used to diff against the next render. */ _prevVTree = null; /** * Schedules a component update via microtask. Multiple calls before the microtask * runs are coalesced into a single render pass. */ updateComponent() { if (this._disconnected) return; if (!this._updateScheduled) { this._updateScheduled = true; queueMicrotask(() => { if (!this._updateScheduled || this._disconnected) return; this._updateScheduled = false; this._performUpdate(); }); } } /** * Performs a synchronous component update, canceling any pending async update. * Used during parent-to-child reconciliation so the entire subtree settles * in a single call frame rather than cascading across microtask ticks. */ updateComponentSync() { if (this._disconnected) return; this._updateScheduled = false; this._performUpdate(); } _performUpdate() { this._pendingHostProps = []; let renderResult; setRenderMode(true); try { renderResult = this.render(this.getRenderOptions()); } finally { setRenderMode(false); } // Apply host props before patching children so that child components // rendered synchronously can discover parent state (e.g. injector) // via getInjectorFromParent(). this._applyHostProps(); const newVTree = toVChildArray(renderResult); patchChildren(this, this._prevVTree || [], newVTree); this._prevVTree = newVTree; } /** * Merges all pending host props from the render pass and applies them * to the host element, diffing against the previously applied host props. */ _applyHostProps() { if (this._pendingHostProps.length === 0) { if (this._prevHostProps) { // All host props were removed — clean up for (const key of Object.keys(this._prevHostProps)) { if (key === 'style') continue; this.removeAttribute(key); } if (this._prevHostProps.style) { for (const sk of Object.keys(this._prevHostProps.style)) { if (sk.startsWith('--')) { this.style.removeProperty(sk); } else { ; this.style[sk] = ''; } } } this._prevHostProps = null; } return; } // Merge all pending host prop calls into a single object const merged = {}; let mergedStyle; for (const hp of this._pendingHostProps) { for (const [key, value] of Object.entries(hp)) { if (key === 'style' && typeof value === 'object' && value !== null) { mergedStyle = { ...mergedStyle, ...value }; } else { merged[key] = value; } } } if (mergedStyle) { merged.style = mergedStyle; } const oldHP = this._prevHostProps || {}; const newHP = merged; // Remove attributes no longer present for (const key of Object.keys(oldHP)) { if (key === 'style') continue; if (!(key in newHP)) { if (key.startsWith('on') && typeof oldHP[key] === 'function') { ; this[key] = null; } else { this.removeAttribute(key); } } } // Apply new/changed attributes for (const [key, value] of Object.entries(newHP)) { if (key === 'style') continue; if (oldHP[key] !== value) { if (typeof value === 'function' || (typeof value === 'object' && value !== null)) { ; this[key] = value; } else if (value === null || value === undefined || value === false) { if (key in this) { ; this[key] = undefined; } this.removeAttribute(key); } else { // eslint-disable-next-line @typescript-eslint/no-base-to-string this.setAttribute(key, String(value)); } } } // Diff styles const oldStyle = oldHP.style || {}; const newStyle = mergedStyle || {}; for (const sk of Object.keys(oldStyle)) { if (!(sk in newStyle)) { if (sk.startsWith('--')) { this.style.removeProperty(sk); } else { ; this.style[sk] = ''; } } } for (const [sk, sv] of Object.entries(newStyle)) { if (oldStyle[sk] !== sv) { if (sk.startsWith('--')) { this.style.setProperty(sk, sv); } else { ; this.style[sk] = sv; } } } this._prevHostProps = merged; } _injector; getInjectorFromParent() { let parent = this.parentElement; while (parent) { if (parent.injector) { return parent.injector; } parent = parent.parentElement; } } get injector() { if (this._injector) { return this._injector; } const fromProps = this.props?.injector; if (fromProps instanceof Injector) { return fromProps; } const fromParent = this.getInjectorFromParent(); if (fromParent) { this._injector = fromParent; return fromParent; } // Fallback for isolated components (tests and non-DI use cases) that // never reach for any services. Components that do resolve tokens // will fail loudly at resolution time, since this throwaway injector // has no bindings. return new Injector(); } set injector(i) { this._injector = i; } }, o.elementBaseName ? { extends: o.elementBaseName } : undefined); } else { throw Error(`A custom shade with name '${o.customElementName}' has already been registered!`); } return (props, children) => { const ElementType = customElements.get(customElementName); const el = new ElementType({ ...props, }); el.props = props || {}; el.shadeChildren = children; if (o.elementBaseName) { el.setAttribute('is', customElementName); } attachStyles(el, { style: o.style }); attachProps(el, props); return el; }; }; /** * Awaits the next microtask tick — long enough for `updateComponent`'s * batching microtask to drain. A single `await flushUpdates()` settles the * entire component tree because child reconciliation is synchronous within * the parent's render. Use in tests before asserting on DOM state. */ export const flushUpdates = () => new Promise((resolve) => queueMicrotask(resolve)); //# sourceMappingURL=shade.js.map