@furystack/shades
Version:
A lightweight UI framework for FuryStack with JSX support
358 lines • 16.5 kB
JavaScript
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