@mr_hugo/boredom
Version:
Another boring JavaScript framework.
467 lines (430 loc) • 14.4 kB
text/typescript
/**
* bore.ts — state and component runtime utilities
*
* Responsibilities:
* - Provide per-instance event scoping for custom events
* - Expose refs/slots accessors for component templates
* - Create read-only state accessors for render-time subscription
* - Proxify mutable state to trigger batched renders (rAF)
* - Initialize component scripts for elements present in the DOM
*/
import { Bored, create, createComponent, isBored, queryComponent } from "./dom";
import {
AppState,
ReadonlyProxy,
Refs,
Slots,
WebComponentDetail,
} from "./types";
import { access } from "./utils/access";
import { flatten } from "./utils/flatten";
import { isPOJO } from "./utils/isPojo";
/**
* Called during initialization. This function sets
* the custom event listeners for all events that modify state.
* This does not register the "update" event (used for modifying the DOM).
*
* @param {State} state The state reference that will be transformed
* at each event
export const addCustomEvents = (state) => {
for (const eventName in allEvents) {
// @ts-ignore
listener(eventName, state, allEvents[eventName]);
}
};
*/
/**
* Listens for an event, and logs it in the event logger
*
* @param {keyof Event} name - the event name to listen
* @param {State} state - the state to transform
* @param {(s: State, e: Event[keyof Event]) => any} h - the handler to call
const listener = (name, state, h) => {
addEventListener(name, (evt) => {
if (evt instanceof CustomEvent) {
// log(name, evt.detail);
h(state, evt.detail);
// Log it
state.runtime.log.push({ e: name });
}
});
};
*/
/** */
/**
* Creates a component-scoped event registration helper used by webComponent.
*
* Scope: The handler only fires when the dispatched custom event originates
* from within the component's DOM subtree (so multiple instances don't cross-talk).
*
* Example:
* ```ts
* const My = webComponent(({ on, state }) => {
* on('increment', () => { state.count++; });
* return () => {};
* });
* // <my-comp><button onclick="dispatch('increment')"></button></my-comp>
* ```
*/
export function createEventsHandler<S>(
c: Bored,
app: S,
detail: WebComponentDetail,
) {
return (
eventName: string,
handler: (
options: {
state: S | undefined;
e: CustomEvent;
detail: WebComponentDetail;
},
) => void,
) => {
addEventListener(eventName as any, (e) => {
let target: HTMLElement | undefined | null = e?.detail?.event
.currentTarget;
let emiterElem: HTMLElement | undefined | null = undefined;
// Only dispatch if the component 'c' is found in the hierarchy:
while (target) {
if (target === c) {
handler({ state: app, e: e.detail, detail });
return;
}
if (target instanceof HTMLElement) {
target = target.parentElement;
} else {
target = undefined;
}
}
});
};
}
/** */
/**
* Provides read-only access to elements marked with data-ref in the component.
* Throws if a requested ref is not found; returns a single element or an array
* when multiple elements share the same ref name.
*
* Example (template):
* ```html
* <p data-ref="label"></p>
* ```
* Example (init/render):
* ```ts
* const { refs } = opts; refs.label.innerText = 'Hello';
* ```
*/
export function createRefsAccessor(c: Bored): ReadonlyProxy<Refs> {
return new Proxy({}, {
get(target, prop, receiver) {
const error = new Error(
`Ref "${String(prop)}" not found in <${c.tagName}>`,
);
if (typeof prop === "string") {
const nodeList = c.querySelectorAll(`[data-ref="${prop}"]`);
if (!nodeList) throw error;
const refs = Array.from(nodeList).filter((ref) =>
ref instanceof HTMLElement
);
if (refs.length === 0) throw error;
if (refs.length === 1) return refs[0];
return refs;
}
},
});
}
/** */
/**
* Exposes named <slot> placeholders. Reading returns the <slot> element(s).
* Setting a slot by name replaces the <slot> in DOM with an element/string,
* and tags it with data-slot for idempotent updates.
*
* Example:
* ```html
* <slot name="title">Default</slot>
* ```
* ```ts
* slots.title = 'My Title'; // replaces the slot
* ```
*/
export function createSlotsAccessor(c: Bored): Slots {
return new Proxy({}, {
get(target, prop, reciever) {
const error = new Error(
`Slot "${String(prop)}" not found in <${c.tagName}>`,
);
if (typeof prop === "string") {
const nodeList = c.querySelectorAll(`slot[name="${prop}"]`);
if (!nodeList) throw error;
const refs = Array.from(nodeList).filter((ref) =>
ref instanceof HTMLSlotElement
);
if (refs.length === 0) throw error;
if (refs.length === 1) return refs[0];
return refs;
}
},
set(target, prop, value) {
if (typeof prop !== "string") return false;
let elem = value;
if (value instanceof HTMLElement) {
value.setAttribute("data-slot", prop);
} else if (typeof value === "string") {
elem = create("span");
elem.setAttribute("data-slot", prop);
elem.innerText = value;
} else {
throw new Error(`Invalid value for slot ${prop} in <${c.tagName}>`);
}
const existingSlots = Array.from(
c.querySelectorAll(`[data-slot="${prop}"]`),
);
if (existingSlots.length > 0) {
existingSlots.forEach((s) => s.parentElement?.replaceChild(elem, s));
} else {
const slots = Array.from(c.querySelectorAll(`slot[name="${prop}"]`));
slots.forEach((s) => s.parentElement?.replaceChild(elem, s));
}
return true;
},
});
}
/**
* Creates a Web Component render updater
*
* @param state
* @param log This array is updated with the paths being accessed
* @param accum
*/
/**
* Creates a read-only proxy view of state for component render-time usage.
* Reading properties records access paths so the render function is subscribed
* to updates on those paths. Mutations inside renders are blocked by design.
*
* Example:
* ```ts
* const s = createStateAccessor(appState, log);
* // reading s.user.name subscribes render to updates on user.name
* ```
*/
export function createStateAccessor<S>(
state: S | undefined,
log: (string[] | string)[],
accum?: {
targets: WeakMap<any, (string | symbol)>;
path: (string | symbol)[];
},
) {
const current = accum || { targets: new WeakMap(), path: [] };
if (state === undefined) return undefined;
return new Proxy(state as any, {
// State accessors are read-only:
set(target, prop, newValue) {
if (typeof prop === "string") {
console.error(
`State is read-only for web components. Unable to set '${prop}'.`,
);
}
return false;
},
// Recursively build a proxy for each state prop being read:
get(target, prop, receiver) {
const value = Reflect.get(target, prop, receiver);
const isProto = prop === "__proto__";
// This is a recursive function, keep track of the target of each prop
// as the inner objects are being traversed
if (typeof prop === "string" && !isProto) {
if (!current.targets.has(target)) {
current.targets.set(target, current.path.join("."));
current.path.push(prop);
}
}
// Go recursive when the value is a nested object:
if (isProto || Array.isArray(value) || isPOJO(value)) {
return createStateAccessor(value, log, current);
}
// Create current path, this is made by appending the current target path
// with the prop being accesed:
let path = current.targets.get(target) ?? "";
if (typeof path === "string" && typeof prop === "string") {
if (Array.isArray(target)) {
// For now the path is kept as is, and all the array is triggered as updated
path;
} else {
path += path !== "" ? `.${prop}` : prop;
}
if (log.indexOf(path) === -1) {
// Only log the path if it is not already logged:
log.push(path);
}
}
current.path.length = 0;
current.path.push(path);
return value;
},
});
}
/** Batches subscriber calls for updated paths into a single rAF tick. */
function createSubscribersDispatcher<S>(state: AppState<S>) {
return () => {
const updates = state.internal.updates;
// Call the subscribers for each path that was updated
for (let i = 0; i < updates.path.length; i++) {
const path = updates.path[i];
const functions =
updates.subscribers.get(path.slice(path.indexOf(".") + 1)) ?? [];
for (let j = 0; j < functions.length; j++) {
functions[j](state.app);
}
}
// clear the updates arrays
updates.path = [];
updates.value = [];
updates.raf = undefined;
};
}
/**
* Registers the callbacks to trigger the state change subscribed functions.
*
* Batches subscribed functions to run in a rAF.
*
* @returns The same reference as provided in argument, but with
* proxies in its attributes.
*/
/**
* Wraps arrays/objects in the app state with Proxies that detect mutations
* and schedule a single rAF to notify subscribed render functions.
*
* Example:
* ```ts
* const app = proxify(initial);
* app.internal.updates.subscribers.set('user.name', [render]);
* app.app.user.name = 'New'; // schedules render(user)
* ```
*/
export function proxify<S>(boredom: AppState<S>) {
const runtime = boredom.internal;
const state = boredom;
if (state === undefined) return boredom;
// Keep track of which objects have been proxified:
const objectsWithProxies = new WeakSet();
// Traverse through all the properties in state
flatten(boredom, ["internal"]).forEach(({ path, value }) => {
const needsProxy = Array.isArray(value) ||
(isPOJO(value) && !objectsWithProxies.has(value));
if (needsProxy) {
const dottedPath = path.join(".");
const parent = access(path.slice(0, -1), state);
// Don't proxify the root
const isRoot = parent === value;
if (isRoot) return;
// @ts-ignore
parent[path.at(-1)] = new Proxy(value, {
set(target, prop, newValue) {
// @ts-ignore Always do the default op when the value is changed
const isChanged = target[prop] !== newValue;
if (!isChanged) return true;
// Update the value and issue the "update" event on the next frame
// Issuing the event on the next frame gives us time to batch a few
// of these in case they are happening too fast, which is a good thing
// since most of the listeners are DOM transformation templates.
Reflect.set(target, prop, newValue);
if (typeof prop !== "string") return true;
if (Array.isArray(value)) {
runtime.updates.path.push(`${dottedPath}`);
} else {
runtime.updates.path.push(`${dottedPath}.${prop}`);
}
runtime.updates.value.push(target);
if (!runtime.updates.raf) {
runtime.updates.raf = requestAnimationFrame(
createSubscribersDispatcher(boredom),
);
}
return true;
},
});
objectsWithProxies.add(value);
}
});
// boredom.app = state.app;
return boredom;
}
/**
* Runs the init function of every webComponent tag that exists in the DOM
*/
/**
* Initializes component scripts for all instances currently in the DOM.
* For each registered tag with a loaded script, calls the webComponent-
* provided function with instance-specific detail including its index.
*
* Example:
* ```html
* <item-card></item-card><item-card></item-card>
* ```
* ```ts
* // both instances receive index 0 and 1 respectively
* runComponentsInitializer(state);
* ```
*/
export function runComponentsInitializer<S>(state: AppState<S>) {
// Start by finding all bored web component tags that are in the dom:
const tagsInDom = state.internal.customTags.filter((tag) =>
// A tag is considered present if at least one instance exists in the DOM
document.querySelector(tag) !== null
);
const components = state.internal.components;
for (const [tagName, code] of components.entries()) {
// Only proceed if there is a registered init function and if the tag is in the DOM
if (code === null || !tagsInDom.includes(tagName)) continue;
// From this point forward, the `code` will be run for tags that are in the dom, this
// way, it prevents the `code` function from being run more than once if a given component
// `code` dynamically creates another component that is not yet in the DOM by now.
const elements = Array.from(
document.querySelectorAll(tagName),
).filter((el): el is Bored => isBored(el));
if (elements.length === 0) {
// No upgraded elements yet; skip and let connectedCallback or later creation handle it
continue;
}
elements.forEach((componentClass, index) => {
code(state as any, { index, name: tagName, data: undefined })(
componentClass,
);
});
}
return;
}
/**
* Creates a web component and runs the associated script if it has one defined.
*
* @param name the tagname of the component to create
* @param state the
* @param [detail]
*/
/**
* Creates a component element and, if a script exists for the tag, wires its
* render callback by invoking the loaded function with the provided detail.
*
* Example:
* ```ts
* const el = createAndRunCode('user-card', appState, { index: 0, name: 'user-card' });
* parent.appendChild(el);
* ```
*/
export function createAndRunCode<S extends object>(
name: string,
state: AppState<S>,
detail?: WebComponentDetail,
) {
// "code" is the function returned by the `webComponent()` (index.ts), it
// creates the state reactive proxy and calls the initialization from
// the corresponding template .js file
const code = state.internal.components.get(name);
if (code) {
const info = { ...detail, tagName: name };
return createComponent(name, code(state as any, info));
}
return createComponent(name);
}