Skip to content

Commit

Permalink
rewrite!: Refactor hooks
Browse files Browse the repository at this point in the history
BREAKING: createHook now only returns a proxy. Closes #10
Static siblings is also now retained. Closes #24
  • Loading branch information
lemonadee71 committed Sep 19, 2022
1 parent e766232 commit 558fc15
Show file tree
Hide file tree
Showing 5 changed files with 169 additions and 118 deletions.
163 changes: 72 additions & 91 deletions src/hooks.js
Original file line number Diff line number Diff line change
@@ -1,59 +1,37 @@
import { isHook, isObject } from './utils/is';
import { getType } from './utils/type';
import { modifyElement } from './utils/modify';
import { uid, compose, resolve } from './utils/util';
import { REF_OBJ } from './constants';
import { modifyElement } from './modify';
import { HOOK_REF } from './constants';
import { isHook } from './utils/is';
import { compose, resolve } from './utils/general';
import { uid } from './utils/id';
import isPlainObject from './utils/is-plain-obj';

const Hooks = new WeakMap();
const HooksRegistry = new WeakMap();

/**
* Creates a hook
* @param {any} value - the initial value of the hook
* @param {Boolean} [seal=true] - seal the object with Object.seal
* @returns {[Object, function]}
* @returns {any}
*/
const createHook = (value, seal = true) => {
let obj = isObject(value) ? value : { value };
let obj = isPlainObject(value) ? value : { value };
obj = seal ? Object.seal(obj) : obj;
Hooks.set(obj, new Map());
HooksRegistry.set(obj, new Map());

const { proxy, revoke } = Proxy.revocable(obj, {
const proxy = new Proxy(obj, {
get: getter,
set: setter,
});

/**
* Delete the hook object and returns the original value
* @returns {any}
*/
const deleteHook = () => {
revoke();
Hooks.delete(obj);

return value;
};

return [proxy, deleteHook];
return proxy;
};

const createHookFunction =
(ref, prop, value) =>
(trap = null) => ({
[REF_OBJ]: ref,
data: {
prop,
trap,
value,
},
});

const methodForwarder = (target, prop) => {
const dummyFn = (value) => value;
const previousTrap = target.data.trap || dummyFn;
const previousTrap = target.data.trap || ((value) => value);

const callback = (...args) => {
const copy = {
[REF_OBJ]: target[REF_OBJ],
[HOOK_REF]: target[HOOK_REF],
data: {
...target.data,
trap: compose(previousTrap, (value) => value[prop](...args)),
Expand All @@ -64,41 +42,51 @@ const methodForwarder = (target, prop) => {
};

// methodForwarder is only for hook/hookFn
// so we're either getting a function or the REF_OBJ or data
// so we're either getting a function or the HOOK_REF or data
// and for user's part, if they are accessing something out of a hook
// we assume that they're getting a function
// this is to avoid invoking the callbacks passed
if ([REF_OBJ, 'data'].includes(prop)) return target[prop];
if ([HOOK_REF, 'data'].includes(prop)) return target[prop];
return callback;
};

const createHookFunction = (ref, prop, value) => {
const fn = (trap = null) => ({
[HOOK_REF]: ref,
data: {
prop,
trap,
value,
},
});

return new Proxy(Object.assign(fn, fn()), { get: methodForwarder });
};

const getter = (target, rawProp, receiver) => {
const [prop, type] = getType(rawProp);
let hook = createHookFunction(target, prop, target[prop]);
hook = Object.assign(hook, hook());
const prop = rawProp.replace(/^\$/, '');

if (type === 'hook' && prop in target) {
return new Proxy(hook, { get: methodForwarder });
if (rawProp.startsWith('$') && prop in target) {
return createHookFunction(target, prop, target[prop]);
}

return Reflect.get(target, prop, receiver);
};

const setter = (target, prop, value, receiver) => {
const bindedElements = Hooks.get(target);
const bindedElements = HooksRegistry.get(target);

bindedElements.forEach((handlers, id) => {
const el = document.querySelector(`[data-proxyid="${id}"]`);
const element = document.querySelector(`[data-proxyid="${id}"]`);

// check if element exists
// otherwise remove handlers
if (el) {
// check if element exists otherwise remove handlers
if (element) {
handlers
.filter((handler) => handler.prop === prop)
.filter((handler) => handler.linkedProp === prop)
.forEach((handler) => {
modifyElement(el, handler.type, {
name: handler.target,
value: resolve(value, handler.trap),
modifyElement(element, handler.type, {
key: handler.targetAttr,
value: resolve(value, handler.action),
});
});
} else {
Expand All @@ -110,48 +98,41 @@ const setter = (target, prop, value, receiver) => {
};

/**
* Add hooks to a DOM element
* @param {HTMLElement} target - the element to add hooks to
* @param {Object} hooks - object that has hooks as values
* @returns {HTMLElement}
* Register the value if hook. Returns resolved value.
* @param {any} value
* @param {Object} options
* @param {HTMLElement} options.element - the element to bind to
* @param {string} options.type - type of modification to make
* @param {string} options.target - the attribute or prop to modify
* @returns {any}
*/
const addHooks = (target, hooks) => {
const id = target.dataset.proxyid || uid();
target.dataset.proxyid = id;

Object.entries(hooks).forEach(([rawKey, value]) => {
if (!isHook(value)) throw new TypeError('Value must be a hook');

const [key, type] = getType(rawKey);

if (['listener', 'lifecycle'].includes(type))
throw new Error(
"You can't dynamically set lifecycle methods or event listeners"
);

const bindedElements = Hooks.get(value[REF_OBJ]);
const handlers = bindedElements.get(id) || [];
const handler = {
type,
target: key,
prop: value.data.prop,
trap: value.data.trap,
};

// store handler
bindedElements.set(id, [...handlers, handler]);
const registerIfHook = (value, options) => {
if (!isHook(value)) return value;

const hook = value;
const id = options.element.dataset.proxyid || uid();
options.element.dataset.proxyid = id;

if (['listener', 'lifecycle'].includes(options.type))
throw new Error(
"You can't dynamically set lifecycle methods or event listeners"
);

const bindedElements = HooksRegistry.get(hook[HOOK_REF]);
const handler = {
type: options.type,
linkedProp: hook.data.prop,
targetAttr: options.target,
action: hook.data.trap,
};

// delete handlers when deleted
target.addEventListener('@destroy', () => bindedElements.delete(id));
// store handler
bindedElements.set(id, [...(bindedElements.get(id) || []), handler]);

// init values
modifyElement(target, handler.type, {
name: handler.target,
value: resolve(value.data.value, handler.trap),
});
});
// delete handlers when deleted
options.element.addEventListener('@destroy', () => bindedElements.delete(id));

return target;
return resolve(hook.data.value, hook.data.trap);
};

export { createHook, addHooks };
export { createHook, registerIfHook };
51 changes: 44 additions & 7 deletions src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,20 @@ import { PLACEHOLDER_REGEX, WRAPPING_QUOTES } from './constants';
import { registerIfHook } from './hooks';
import { triggerLifecycle } from './lifecycle';
import { modifyElement } from './modify';
import { getChildNodes, getChildren, traverse } from './utils/dom';
import { escapeHTML, getPlaceholderId, split } from './utils/general';
import {
getChildNodes,
getChildren,
splitTextNodes,
traverse,
} from './utils/dom';
import { escapeHTML, getPlaceholderId } from './utils/general';
import { addTrap, createMarkers, getBoundary } from './utils/hooks';
import { uid } from './utils/id';
import {
isArray,
isFragment,
isFunction,
isHook,
isNullOrUndefined,
isObject,
isPlaceholder,
Expand Down Expand Up @@ -134,17 +141,45 @@ const createElementFromTemplate = (template) => {
}
}
}
// Split all placeholders into its separate text node
splitTextNodes(element);

// then replace the placeholder text nodes
const textNodes = getChildNodes(element).filter(isTextNode);
for (const node of textNodes) {
const text = node.textContent.trim();

if (isPlaceholder(text)) {
const match = text.match(PLACEHOLDER_REGEX);
const [left, right] = split(text, match[0]);
const value = template.values[getPlaceholderId(match[0])];
let value = template.values[getPlaceholderId(text)];

// COMMIT: Hooks
if (isHook(value)) {
const [head, tail, marker] = createMarkers();

node.replaceWith(head, tail);

// Register the hook
let hook = value;
hook = addTrap(hook, (newValue) => {
const nodes = getChildNodes(element);
const [start, end] = getBoundary(marker, nodes);

return normalizeChildren(
[nodes.slice(0, start), newValue, nodes.slice(end)].flat()
);
});

value = registerIfHook(hook, { element, type: 'children' });
value = value.slice(...getBoundary(marker, value));

// Then render initial value
tail.replaceWith(...value, tail);

continue;
}
// END

node.replaceWith(left, ...normalizeChildren(value), right);
node.replaceWith(...normalizeChildren(value));
}
}
});
Expand Down Expand Up @@ -174,7 +209,9 @@ const resolveValue = (value, options) => {
let final = value;

if (options.type === 'children') {
final = normalizeChildren(final);
final = isHook(final)
? addTrap(final, normalizeChildren)
: normalizeChildren(final);
}

return registerIfHook(final, options);
Expand Down
20 changes: 0 additions & 20 deletions src/utils/general.js
Original file line number Diff line number Diff line change
Expand Up @@ -102,23 +102,3 @@ export const venn = (first, second, transform = null) => {
right,
};
};

/**
* Split the string into left and right side.
* Only splits on the first match.
* @param {string} str
* @param {string|RegExp} delimiter
* @returns {string[]}
*/
export const split = (str, delimiter) => {
const match = str.match(delimiter);
let left = str;
let right = '';

if (match) {
left = str.slice(0, match.index);
right = str.slice(match.index + match[0].length);
}

return [left, right];
};
47 changes: 47 additions & 0 deletions src/utils/hooks.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { compose, resolve } from './general';
import { uid } from './id';
import { getKey, setMetadata } from './meta';

/**
* Append a callback to the hook
* @param {any} hook
* @param {Function} callback
* @returns {any}
*/
export const addTrap = (hook, callback) => {
const previousTrap = hook.data.trap;
hook.data.trap = compose((value) => resolve(value, previousTrap), callback);

return hook;
};

/**
* Get the index of bounding comment markers
* @param {string} id
* @param {Node[]} nodes
* @returns {[number, number]}
*/
export const getBoundary = (id, nodes) => {
const start = nodes.findIndex((n) => getKey(n) === `start_${id}`);
const end = nodes.findIndex((n) => getKey(n) === `end_${id}`);

return [start + 1, end];
};

/**
* Create marker comments to easily mark the start and end
* of where the hook is passed in the body
* @returns {[Comment,Comment,string]}
*/
export const createMarkers = () => {
const id = uid();

// Use comments to easily mark the start and end
// of where we should insert our children
const head = document.createComment('MARKER');
const tail = document.createComment('END');
setMetadata(head, 'key', `start_${id}`);
setMetadata(tail, 'key', `end_${id}`);

return [head, tail, id];
};
6 changes: 6 additions & 0 deletions src/utils/is.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { PLACEHOLDER_REGEX } from '../../rewrite/constants';
import { HOOK_REF } from '../constants';
import Template from './Template';

export const isNullOrUndefined = (value) =>
Expand Down Expand Up @@ -31,3 +32,8 @@ export const isTruthy = (value) =>
value &&
// handles strings
!['0', 'false', 'null', 'undefined', 'NaN'].includes(value);

export const isHook = (value) => {
if (isObject(value) || isFunction(value)) return !!value[HOOK_REF];
return false;
};

0 comments on commit 558fc15

Please sign in to comment.