<!doctype html>
<html>
<head>
<link rel="stylesheet" href="lib/style.css">
<script type="module"src="examples/parent-child.js"></script>
</head>
<body id="app">
<h1>Hello Plunker!</h1>
</body>
</html>
import Outlet from "./outlet.js";
import diffHTML from "./diffHTML.js";
import bindEvents from "./bindEvents.js";
export * from "./context.js";
import { context } from "./context.js";
import injectSlotContent from "./injectSlotContent.js";
let id = 0;
function normalizeSlots(props) {
const slots = Object.assign(props.slots || {})
if (!Object.keys(slots).length && props.children) {
slots.default = props.children;
}
if(
typeof slots === "function"||
!Object.keys(slots).length) {
slots['default'] = slots.default || slots;
}
props.slots = slots
return props
}
function addDefaultDataSlotName(htmlString) {
// Regex to match <slot> tags that DO NOT have a data-slot-name attribute
const regex = /<slot(?![^>]*\sdata-slot-name="[^"]*")[^>]*>/gi;
return htmlString.replace(regex, (match) => {
// If the slot already has a closing slash (e.g., <slot />), ensure it's kept
// We add data-slot-name="default" before the closing bracket
return match.replace(/>|\/>/, (match) => {
return ` data-slot-name="default"${match}`
});
});
}
export function createComponent(
renderFn,
{
state: initialState = {},
on = {},
onMount,
onUnmount,
onBeforeMount,
onBeforeUnmount,
onUpdate,
} = {}
) {
let el = null;
let mounted = false;
let props = {};
let prevProps = {};
let scheduledRenderProps = null;
let onBeforeMountDone = false;
let boundEvents = [];
let state = initialState;
let _cachedNode = null;
let _renderedNull = false;
const originalInitialState = { ...initialState };
let renderScheduled = false;
let contextUnsubs = [];
const setState = (next) => {
state = typeof next === "function" ? next(state) : { ...state, ...next };
if (!renderScheduled) {
renderScheduled = true;
scheduledRenderProps = { ...props };
queueMicrotask(() => {
renderScheduled = false;
componentFn(scheduledRenderProps);
scheduledRenderProps = null;
});
}
};
const api = {
refs: {},
setState,
props,
mount(targetOrSelector, initialProps = {}) {
if (mounted || onBeforeMountDone) return;
let target =
typeof targetOrSelector === "string"
? document.querySelector(targetOrSelector)
: targetOrSelector;
if (!target) throw new Error(`No element matches: ${targetOrSelector}`);
const proceed = () => {
onBeforeMountDone = true;
el = target;
props = { ...props, ...initialProps };
prevProps = { ...props };
api.props = props;
componentFn(props);
// ✅ Automatically hydrate component instances passed to props.children or props.slots
normalizeSlots(props)
Object.entries(props.slots).forEach(([key, value]) => {
let ntarget = componentFn.refs[key];
if (!ntarget || value == null) return;
injectSlotContent(ntarget, value);
});
bindEvents(componentFn, el, on, boundEvents); // ✅ ensure this.el is hydrated before context
if (!_renderedNull) {
mounted = true;
onMount?.call(componentFn);
setupContextListeners();
// syncInstanceToAPI(api, componentFn);
}
};
runBeforeHook(onBeforeMount, proceed);
},
update(input = {}) {
if (!mounted) return;
const nextProps = input.props ? input.props : input;
const newOn = nextProps.on;
if (newOn && newOn !== on) {
on = newOn;
}
const merged = { ...props, ...nextProps };
prevProps = { ...props };
props = merged;
api.props = props;
componentFn(props);
},
render(newProps = {}) {
props = { ...props, ...newProps };
api.props = props;
let html =
typeof renderFn === "function"
? renderFn.call(api, { state, setState, props, refs: api.refs })
: renderFn;
html = addDefaultDataSlotName(html)
html = Outlet(html, props);
if (html) {
html = new String(html);
html._id = componentFn._id;
}
return html;
},
_resetInternal: () => {
_cachedNode = null;
_renderedNull = false;
el = null; // <-- Add this line
mounted = false;
onBeforeMountDone = false;
boundEvents = [];
props = {};
prevProps = {};
state = { ...originalInitialState };
},
unmount() {
if (!mounted || !el) return;
const cleanup = () => {
if (el.firstChild) {
_cachedNode = el.firstChild.cloneNode(true);
el.removeChild(el.firstChild);
}
boundEvents.forEach(({ node, type, listener }) => {
node.removeEventListener(type, listener);
});
boundEvents = [];
contextUnsubs.forEach((unsub) => unsub());
contextUnsubs = [];
mounted = false;
onBeforeMountDone = false;
_renderedNull = true;
onUnmount?.call(api);
if (api._resetInternal) api._resetInternal();
};
runBeforeHook(onBeforeUnmount, cleanup);
},
get renderFn() {
return renderFn;
},
get el() {
return el;
},
get state() {
return state;
},
};
api.ref = function (name) {
if (!this.el) return null;
return (
this.el.querySelector(`[data-ref="${name}"]`) ||
this.el.querySelector(`slot[data-slot-name="${name}"]`) ||
this.el.querySelector(name) ||
null
);
};
api.refs = new Proxy(
{},
{
get(_, key) {
return api.ref(key);
},
}
);
api.action = function (e) {
return e.target.closest("[data-action]")?.dataset.action || null;
};
function setupContextListeners() {
contextUnsubs = [];
Object.entries(on).forEach(([key, handler]) => {
if (key.includes("::")) {
const bound = handler.bind(api);
const unsub = context.subscribe(key, bound);
contextUnsubs.push(unsub);
}
});
}
function render(currentProps) {
props = currentProps;
api.props = props;
const html = api.render(props);
if (html === null || html === "") {
if (!_renderedNull && el && el.firstChild) {
const realNode = el.firstChild;
_cachedNode = realNode.cloneNode(true);
runBeforeHook(onBeforeUnmount, () => {
// Remove all child nodes, not just first
el.innerHTML = "";
boundEvents.forEach(({ node, type, listener }) => {
node.removeEventListener(type, listener);
});
boundEvents = [];
mounted = false;
_renderedNull = true;
onUnmount?.call(api);
// syncInstanceToAPI(api, componentFn)
});
} else {
_renderedNull = true;
}
return;
}
if (_renderedNull && _cachedNode) {
el.appendChild(_cachedNode);
_cachedNode = null;
_renderedNull = false;
mounted = true;
bindEvents(api, el, on, boundEvents);
onMount?.call(api);
// syncInstanceToAPI(api, componentFn);
return;
}
_renderedNull = false;
diffHTML(el, html);
bindEvents(api, el, on, boundEvents);
if (mounted && onUpdate) {
onUpdate.call(api, prevProps);
}
// syncInstanceToAPI(api, componentFn);
}
function runBeforeHook(hook, next) {
if (hook) {
if (hook.length) hook.call(api, next);
else Promise.resolve(hook.call(api)).then(next);
} else {
next();
}
}
// Create the callable component function
function componentFn(props = {}) {
componentFn._render(props);
// syncInstanceToAPI(api, componentFn);
return componentFn;
}
// Attach API methods
Object.assign(componentFn, api);
// Dynamically attach instance-backed getters
["el", "props", "state", "setState"].forEach((key) => {
Object.defineProperty(componentFn, key, {
get() {
return api[key];
},
enumerable: true,
});
});
componentFn._render = render;
componentFn.render = api.render;
componentFn._id = id++;
return componentFn;
}
export function renderList(
array,
renderFn,
keyFn = (item) => item.id ?? item.key ?? item
) {
return array
.map((item, index) => {
const key = keyFn(item, index);
const inner = renderFn(item, index);
// Only add data-key if inner is string and starts with a tag
if (typeof inner === "string") {
return inner.replace(
/^<([a-zA-Z0-9-]+)/,
`<$1 data-key="${String(key).replace(/"/g, """)}"`
);
}
return inner;
})
.join("");
}
export default function bindEvents(api, el, on, boundEvents) {
if (!el || !on) return;
const root = el.firstElementChild;
if (!root) return;
Object.entries(on).forEach(([key, handler]) => {
const isColonSyntax = key.includes(":");
const [eventType, actionOrSelector] = isColonSyntax
? key.split(":")
: key.split(" ");
const isWildcardEvent = eventType === "*";
const eventsToBind = isWildcardEvent
? ["click", "input", "change", "keydown", "submit"]
: [eventType];
eventsToBind.forEach((type) => {
const listener = (e) => {
const actionTarget = e.target.closest("[data-action]");
const actionValue = actionTarget?.dataset.action || "";
const dataArgsRaw = actionTarget?.dataset.args;
const actionParts = actionValue.split(":");
const actionName = actionParts[0];
let actionArgs = [];
// Prefer data-args if present
if (dataArgsRaw) {
try {
const parsed = JSON.parse(dataArgsRaw);
actionArgs = Array.isArray(parsed) ? parsed : [parsed];
} catch (err) {
console.warn("Invalid JSON in data-args:", dataArgsRaw);
}
} else {
actionArgs = actionParts.slice(1);
}
const context = {
event: e,
state: api.state,
setState: api.setState,
props: api.props,
refs: api.refs,
action: actionName,
args: actionArgs,
};
// Action handler
if (isColonSyntax && actionTarget) {
const matches =
(actionOrSelector === "*" || actionOrSelector === actionName) &&
(isWildcardEvent || e.type === eventType);
if (matches) {
return handler.call(api, context);
}
}
// Fallback to selector-based if no action match
if (!isColonSyntax) {
const target = e.target.closest(actionOrSelector || "*");
if (target && root.contains(target)) {
return handler.call(api, context);
}
}
};
const marker = `__microBound_${type}_${key}`;
if (!root[marker]) {
root.addEventListener(type, listener);
root[marker] = true;
boundEvents.push({ node: root, type, listener });
}
});
});
}
export function createState(initial) {
let state = initial;
const subs = new Set();
function setState(next) {
state = typeof next === "function" ? next(state) : { ...state, ...next };
subs.forEach((fn) => fn(state));
}
function subscribe(fn) {
subs.add(fn);
fn(state);
return () => subs.delete(fn);
}
function get() {
return state;
}
return { get, setState, getState: get, subscribe };
}
// Top-level shared pub/sub
const channels = new Map();
export const context = {
subscribe(eventName, fn) {
if (!channels.has(eventName)) {
channels.set(eventName, new Set());
}
const set = channels.get(eventName);
set.add(fn);
return () => set.delete(fn);
},
emit(eventName, payload) {
const set = channels.get(eventName);
if (set) {
for (const fn of set) fn(payload);
}
},
clear() {
channels.clear(); // Optional: useful for testing or full reset
},
};
const stores = new Map();
export function shared(key, initial = {}) {
if (!stores.has(key)) {
const state = createState(initial);
const api = {
...state,
emit(event, payload) {
state.setState(payload);
context.emit(`${key}::${event}`, state.getState());
},
on(event, fn) {
return context.subscribe(`${key}::${event}`, fn);
},
};
stores.set(key, api);
}
return stores.get(key);
}
shared.clear = () => stores.clear();
export default function diffHTML(el, newHTML) {
if (!el) return false;
// Optional: parse with cache using newHTML._id
const temp = document.createElement("div");
temp.innerHTML = newHTML;
// Special case: if newHTML is plain text (no elements)
if (temp.children.length === 0 && temp.textContent) {
el.textContent = temp.textContent;
return true;
}
const newChildren = Array.from(temp.children);
const oldChildren = Array.from(el.children);
const newKeyed = new Map();
const oldKeyed = new Map();
for (const child of newChildren) {
const key = child.dataset.key;
if (key) newKeyed.set(key, child);
}
for (const child of oldChildren) {
const key = child.dataset.key;
if (key) oldKeyed.set(key, child);
}
let cursor = 0;
for (const newChild of newChildren) {
const key = newChild.dataset.key;
let currentNode = el.children[cursor];
if (key && oldKeyed.has(key)) {
const existing = oldKeyed.get(key);
patchElement(existing, newChild);
if (existing !== currentNode) {
el.insertBefore(existing, currentNode || null);
}
oldKeyed.delete(key);
} else {
el.insertBefore(newChild, currentNode || null);
}
cursor++;
}
for (const leftover of oldKeyed.values()) {
leftover.remove();
}
while (el.children.length > newChildren.length) {
el.lastChild.remove();
}
return true;
}
function patchElement(fromEl, toEl) {
if (fromEl.tagName !== toEl.tagName) {
fromEl.replaceWith(toEl.cloneNode(true));
return;
}
syncAttributes(fromEl, toEl);
patchChildren(fromEl, toEl);
}
function syncAttributes(fromEl, toEl) {
const fromAttrs = fromEl.attributes;
const toAttrs = toEl.attributes;
for (const { name } of Array.from(fromAttrs)) {
if (!toEl.hasAttribute(name)) {
fromEl.removeAttribute(name);
}
}
for (const { name, value } of Array.from(toAttrs)) {
if (fromEl.getAttribute(name) !== value) {
fromEl.setAttribute(name, value);
}
}
}
function patchChildren(fromEl, toEl) {
const fromNodes = Array.from(fromEl.childNodes);
const toNodes = Array.from(toEl.childNodes);
const max = Math.max(fromNodes.length, toNodes.length);
for (let i = 0; i < max; i++) {
const fromNode = fromNodes[i];
const toNode = toNodes[i];
if (!toNode && fromNode) {
fromEl.removeChild(fromNode);
continue;
}
if (!fromNode && toNode) {
fromEl.appendChild(toNode.cloneNode(true));
continue;
}
patchNode(fromNode, toNode);
}
}
function patchNode(fromNode, toNode) {
if (fromNode.isEqualNode(toNode)) return;
if (fromNode.nodeType !== toNode.nodeType) {
fromNode.replaceWith(toNode.cloneNode(true));
return;
}
if (fromNode.nodeType === Node.TEXT_NODE) {
if (fromNode.nodeValue !== toNode.nodeValue) {
fromNode.nodeValue = toNode.nodeValue;
}
} else if (
fromNode.nodeType === Node.ELEMENT_NODE &&
fromNode.tagName === toNode.tagName
) {
patchElement(fromNode, toNode);
} else {
fromNode.replaceWith(toNode.cloneNode(true));
}
}
// injectSlotContent.js
export default function injectSlotContent(refNode, value) {
if (!refNode || value == null) return;
const resolveItem = (item) => (typeof item === "function" ? item() : item);
const mountOrAppend = (item, target) => {
if (item?.mount instanceof Function) {
const temp = document.createElement("div");
item.mount(temp);
if (temp.firstElementChild) target.appendChild(temp.firstElementChild);
} else if (item?.el instanceof HTMLElement) {
target.appendChild(item.el);
} else if (item instanceof Node) {
target.appendChild(item);
} else if (typeof item === "string") {
const temp = document.createElement("div");
temp.innerHTML = item;
if (temp.firstElementChild) target.appendChild(temp.firstElementChild);
}
};
const resolved = resolveItem(value);
if (Array.isArray(resolved)) {
const fragment = document.createDocumentFragment();
resolved.forEach((item) => mountOrAppend(resolveItem(item), fragment));
refNode.replaceWith(fragment);
return;
}
// Single item
const tempContainer = document.createElement("div");
if (resolved?.mount instanceof Function) {
refNode.replaceWith(tempContainer);
resolved.mount(tempContainer);
} else if (resolved?.el instanceof HTMLElement) {
refNode.replaceWith(resolved.el);
} else if (resolved instanceof Node) {
refNode.replaceWith(resolved);
} else if (typeof resolved === "string") {
tempContainer.innerHTML = resolved;
if (tempContainer.firstElementChild) {
refNode.replaceWith(tempContainer.firstElementChild);
}
} else {
refNode.replaceWith(document.createTextNode(""));
}
}
export default function Outlet(html, props = {}) {
if (typeof html !== "string") return ""; // return empty string if null or non-string
const { children = {}, slots = {} } = props;
function getSlotContent(name, fallback) {
let value;
if (!name) {
if (
children &&
typeof children === "object" &&
children.default != null
) {
value = children.default;
} else if (slots && typeof slots === "object" && slots.default != null) {
value = slots.default;
} else if (typeof children === "string") {
value = children;
} else {
return fallback;
}
} else {
value = children?.[name] ?? slots?.[name] ?? fallback;
}
if (typeof value === "function") {
value = value();
}
if (value?.el instanceof HTMLElement) {
return value.el.outerHTML; // insert HTML, real DOM will be handled later
}
if (value instanceof Node) {
const temp = document.createElement("div");
temp.appendChild(value.cloneNode(true));
return temp.innerHTML;
}
return value ?? fallback;
}
html = html.replace(
/<slot(?:\s+name="([^"]+)")?>([\s\S]*?)<\/slot>/gis,
(_, name, fallback) => getSlotContent(name, fallback)
);
html = html.replace(
/<([a-z]+)([^>]*)\sdata-slot="([^"]+)"([^>]*)>([\s\S]*?)<\/\1>/gis,
(_, tag, before, name, after, fallback) => {
const content = getSlotContent(name, fallback);
return `<${tag}${before}${after}>${content}</${tag}>`;
}
);
return html;
}
import {createComponent} from "../lib/reactive-core.js"
const Child = createComponent(({state}) => {
return `<div data-key="1"><p>I'm the child</p>
<button>Count ${state.count ?? ""}</button></div>
`
},{
state: {count: 0},
on: {
"click button"(){
this.setState(prev =>
({count: ++prev.count})
)
}
}
});
const Parent = createComponent(() => `
<div class="card">
<header data-slot-name="header"></header>
<main><slot>DEfaulttttt</slot></main>
<footer data-slot-name="footer">Default Footer</footer>
</div>
`);
let props = {
slots: {
//default: "<p>Hello world!</p>",
footer: "<p>Custom footer here</p>"
}
}
props = {
slots: Child(),
//slots:{footer: Child()},
//children: Child(),
}
Parent.mount("#app",props);