Current section
Files
Jump to
Current section
Files
src/off_topic/internal/component.mjs
import * as Subscriptions from "./subscriptions.mjs";
import * as Commands from "./commands.mjs";
class OffTopicClientRuntime extends HTMLElement {
#root = null;
#subscriptions = new Map();
#connected = false;
connectedCallback() {
this.#root = findRoot(this);
if (this.#root) {
this.#root.addEventListener("off-topic:subscribe", this.#onSubscribe);
this.#root.addEventListener("off-topic:unsubscribe", this.#onUnsubscribe);
this.#root.addEventListener("off-topic:command", this.#onCommand);
this.#root.addEventListener("lustre:close", this.#onClose);
this.#root.addEventListener("lustre:open", this.#onOpen);
}
if (!this.#connected) {
this.#onOpen();
}
}
connectedMoveCallback() {}
disconnectedCallback() {
if (this.#root) {
this.#root.removeEventListener("off-topic:subscribe", this.#onSubscribe);
this.#root.removeEventListener(
"off-topic:unsubscribe",
this.#onUnsubscribe,
);
this.#root.removeEventListener("lustre:close", this.#onClose);
this.#root.addEventListener("lustre:open", this.#onOpen);
this.#root = null;
}
queueMicrotask(() => {
if (this.isConnected) return;
this.#onClose();
});
}
#onSubscribe = (event) => {
const {
detail: { tick, path, name, params, include, throttle = 0, delay = 0 },
} = event;
const key = `${tick}:${path.join(":")}`;
if (this.#subscriptions.has(key)) return;
const start = window.OffTopic.subscriptions[name];
if (!start) {
console.warn(
`off-topic: subscription '${name}' requested, but not registered`,
);
return;
}
const state = {
tick,
path,
params,
cleanup: undefined,
lastFired: 0,
lastThrottledData: undefined,
delayTimeout: undefined,
};
const dispatch = (data) => {
const message = Array.isArray(data)
? data.map((arg) => pickPaths(arg, include))
: pickPaths(data, include);
const ev = new CustomEvent("off-topic:dispatch", {
detail: { tick, path, message },
});
this.dispatchEvent(ev);
};
const handler = (...args) => {
const data = args.length === 1 ? args[0] : args;
if (throttle > 0) {
const now = Date.now();
if (now >= state.lastFired + throttle) {
state.lastFired = now;
state.lastThrottledData = data;
dispatch(data);
}
}
if (delay > 0) {
clearTimeout(state.delayTimeout);
state.delayTimeout = setTimeout(() => {
if (data === state.lastThrottledData) return;
dispatch(data);
}, delay);
}
if (!throttle && !delay) {
dispatch(data);
}
};
state.cleanup = start(...params, handler, this.#root);
this.#subscriptions.set(key, state);
};
#onUnsubscribe = (event) => {
const {
detail: { tick, path },
} = event;
const key = `${tick}:${path.join(":")}`;
const subscription = this.#subscriptions.get(key);
if (!subscription) return;
this.#subscriptions.delete(key);
clearTimeout(subscription.delayTimeout);
subscription.cleanup?.();
};
#onCommand = (event) => {
const {
detail: { name, params },
} = event;
window.OffTopic.commands[name]?.(...params, this.#root);
};
#onOpen = () => {
this.dispatchEvent(new CustomEvent("off-topic:mount"));
this.#connected = true;
};
#onClose = () => {
for (const subscription of this.#subscriptions.values()) {
clearTimeout(subscription.delayTimeout);
subscription.cleanup?.();
}
this.#subscriptions.clear();
this.#connected = false;
};
}
function findRoot(node) {
while (node != null) {
if (
node instanceof ShadowRoot &&
node.host.tagName.toLowerCase() === "lustre-server-component"
) {
return node.host;
}
node = node.parentNode;
}
console.error(
"<ot-client-runtime> is not mounted inside a Lustre server component and will not work!\n" +
"If you are using lustre.start, you can use off_topic's effects directly and can remove this component.",
);
return null;
}
// -- HELPERS -----------------------------------------------------------------
function pickPaths(event, include) {
if (typeof event !== "object") {
return event;
}
const data = {};
for (const property of include) {
const path = property.split(".");
for (let i = 0, input = event, output = data; i < path.length; i++) {
// If we're at the end of the path we just do a straight assignment. If the
// value at this path is an object it's likely the properties are still
// unenumerable, but that's what they asked for!
if (i === path.length - 1) {
output[path[i]] = input[path[i]];
} else {
// For every step, we make sure to insert an empty object if we haven't
// already visited this particular key in the path.
output = output[path[i]] ??= {};
input = input[path[i]];
}
}
}
return data;
}
// -- REGISTER ----------------------------------------------------------------
window.OffTopic ??= {};
window.OffTopic.subscriptions ??= {};
window.OffTopic.commands ??= {};
customElements.define("ot-client-runtime", OffTopicClientRuntime);
Object.assign(window.OffTopic.subscriptions, {
"off-topic/event": Subscriptions.event,
"off-topic/page-state": Subscriptions.on_page_state,
"off-topic/prevent-unload": Subscriptions.prevent_unload,
"off-topic/media-query": Subscriptions.media_query,
"off-topic/window-hover": Subscriptions.window_hover,
"off-topic/here": Subscriptions.here,
"off-topic/window-size": Subscriptions.window_size,
"off-topic/window-scroll": Subscriptions.window_scroll,
"off-topic/screen-orientation": Subscriptions.screen_orientation,
"off-topic/language": Subscriptions.language,
"off-topic/local-storage": Subscriptions.local_storage,
"off-topic/session-storage": Subscriptions.session_storage,
"off-topic/title": Subscriptions.title,
"off-topic/meta": Subscriptions.meta,
"off-topic/body-class": Subscriptions.body_class,
"off-topic/aria": Subscriptions.aria,
"off-topic/pseudo-state": Subscriptions.pseudo_state,
"off-topic/form-value": Subscriptions.form_value,
"off-topic/element-size": Subscriptions.element_size,
"off-topic/element-scroll": Subscriptions.element_scroll,
"off-topic/element-visible": Subscriptions.element_visible,
"off-topic/on-idle": Subscriptions.on_idle,
});
Object.assign(window.OffTopic.commands, {
"off-topic/set-local-storage": Commands.set_local_storage,
"off-topic/set-session-storage": Commands.set_session_storage,
"off-topic/scroll-to": Commands.scroll_to,
"off-topic/scroll-into-view": Commands.scroll_into_view,
"off-topic/focus": Commands.focus,
"off-topic/blur": Commands.blur,
});