diff --git a/README.md b/README.md index 60bdd3b4..0fb09b26 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ Nullstack -Feature-Driven Fullstack JavaScript Components +Feature-Driven Full Stack JavaScript Components ## What is Nullstack? -Nullstack is a Feature-Driven fullstack JavaScript framework that helps you build isomorphic applications and stay focused on shipping products to production. +Nullstack is a feature-driven full stack JavaScript framework that helps you build isomorphic applications and stay focused on shipping products to production. Write the backend and frontend of a feature in a single component and let the framework decide where the code should run. diff --git a/client/anchorableNode.js b/client/anchorableNode.js index bee172e6..7c4d06a7 100644 --- a/client/anchorableNode.js +++ b/client/anchorableNode.js @@ -3,8 +3,6 @@ import router from './router' export function anchorableElement(element) { const links = element.querySelectorAll('a[href^="/"]:not([target])') for (const link of links) { - if (link.dataset.nullstack) return - link.dataset.nullstack = true link.addEventListener('click', (event) => { if (!event.ctrlKey && !event.shiftKey && !event.altKey && !event.metaKey) { event.preventDefault() diff --git a/client/client.js b/client/client.js index 798dfce9..15f0a670 100644 --- a/client/client.js +++ b/client/client.js @@ -7,7 +7,6 @@ import router from './router' const client = {} client.initialized = false -client.hydrated = false client.initializer = null client.instances = {} context.instances = client.instances @@ -20,6 +19,12 @@ client.selector = null client.events = {} client.generateContext = generateContext client.renderQueue = null +client.currentBody = {} +client.nextBody = {} +client.currentHead = [] +client.nextHead = [] +client.head = document.head +client.body = document.body client.update = async function update() { if (client.initialized) { @@ -30,11 +35,14 @@ client.update = async function update() { scope.plugins = loadPlugins(scope) client.initialized = false client.renewalQueue = [] - client.nextVirtualDom = await generateTree(client.initializer(), scope) - rerender(client.selector) - client.virtualDom = client.nextVirtualDom - client.nextVirtualDom = null - client.processLifecycleQueues() + try { + client.nextVirtualDom = await generateTree(client.initializer(), scope) + rerender() + client.processLifecycleQueues() + } catch (e) { + client.skipHotReplacement = true + console.error(e) + } }, 16) } } @@ -42,15 +50,22 @@ client.update = async function update() { client.processLifecycleQueues = async function processLifecycleQueues() { if (!client.initialized) { client.initialized = true - client.hydrated = true } let shouldUpdate = false + let shouldScroll = router._hash while (client.initiationQueue.length) { const instance = client.initiationQueue.shift() instance.initiate && await instance.initiate() - instance._self.initiated = true + instance.initiated = true instance.launch && instance.launch() shouldUpdate = true + if (instance._attributes.route && shouldScroll) { + const element = document.getElementById(router._hash) + if (element) { + element.scrollIntoView({ behavior: 'smooth' }) + } + shouldScroll = false + } } shouldUpdate && client.update() shouldUpdate = false @@ -58,7 +73,7 @@ client.processLifecycleQueues = async function processLifecycleQueues() { shouldUpdate = true const instance = client.realHydrationQueue.shift() instance.hydrate && await instance.hydrate() - instance._self.hydrated = true + instance.hydrated = true } shouldUpdate && client.update() shouldUpdate = false @@ -70,10 +85,10 @@ client.processLifecycleQueues = async function processLifecycleQueues() { shouldUpdate && client.update() for (const key in client.instances) { const instance = client.instances[key] - if (!client.renewalQueue.includes(instance) && !instance._self.terminated) { + if (!client.renewalQueue.includes(instance) && !instance.terminated) { instance.terminate && await instance.terminate() - if (instance._self.persistent) { - instance._self.terminated = true + if (instance.persistent) { + instance.terminated = true } else { delete client.instances[key] } diff --git a/client/environment.js b/client/environment.js index d4c42e7f..246ce1f6 100644 --- a/client/environment.js +++ b/client/environment.js @@ -1,6 +1,10 @@ import state from './state'; -const environment = { ...state.environment, client: true, server: false }; -Object.freeze(environment); +const environment = { + ...state.environment, + client: true, + server: false, + event: 'nullstack.environment' +}; export default environment; \ No newline at end of file diff --git a/client/events.js b/client/events.js new file mode 100644 index 00000000..7ac83483 --- /dev/null +++ b/client/events.js @@ -0,0 +1,72 @@ +import router from './router' +import { camelize } from '../shared/string'; +import noop from '../shared/noop' + +export const eventCallbacks = new WeakMap() +export const eventSubjects = new WeakMap() +export const eventDebouncer = new WeakMap() + +function executeEvent(callback, subject, event, data) { + if (typeof callback === 'object') { + Object.assign(subject.source, callback); + } else { + callback({ ...subject, event, data }); + } +} + +function debounce(selector, name, time, callback) { + if (!time) { + callback() + } else { + const eventMap = eventDebouncer.get(selector) || {} + clearTimeout(eventMap[name]) + eventMap[name] = setTimeout(callback, time) + eventDebouncer.set(selector, eventMap) + } +} + +export function generateCallback(selector, name) { + return function eventCallback(event) { + const subject = eventSubjects.get(selector) + if (!subject) return + if (subject.href) { + if (!event.ctrlKey && !event.shiftKey && !event.altKey && !event.metaKey) { + event.preventDefault() + router.url = subject.href + } + } else if (subject.default !== true) { + event.preventDefault(); + } + debounce(selector, name, subject.debounce, () => { + const data = { ...subject.data } + for (const attribute in subject) { + if (attribute.startsWith('data-')) { + const key = camelize(attribute.slice(5)); + data[key] = subject[attribute]; + } + } + if (subject?.bind !== undefined) { + const valueName = (subject.type === 'checkbox' || subject.type === 'radio') ? 'checked' : 'value' + const object = subject.bind.object + const property = subject.bind.property + if (valueName === 'checked') { + object[property] = event.target[valueName]; + } else if (object[property] === true || object[property] === false) { + object[property] = event.target[valueName] === 'true'; + } else if (typeof object[property] === 'number') { + object[property] = +event.target[valueName] || 0; + } else { + object[property] = event.target[valueName]; + } + } + if (subject[name] === noop) return + if (Array.isArray(subject[name])) { + for (const subcallback of subject[name]) { + executeEvent(subcallback, subject, event, data) + } + } else { + executeEvent(subject[name], subject, event, data) + } + }) + } +}; \ No newline at end of file diff --git a/client/hydrate.js b/client/hydrate.js new file mode 100644 index 00000000..e07821ac --- /dev/null +++ b/client/hydrate.js @@ -0,0 +1,50 @@ +import { ref } from './ref'; +import { isFalse } from '../shared/nodes'; +import { anchorableElement } from './anchorableNode'; +import client from './client'; + +let pool = [] + +function hydrateBody(selector, node) { + if (node?.attributes?.html) { + anchorableElement(selector); + } + node.element = selector + ref(node.attributes, selector) + for (const element of selector.childNodes) { + if ((element.tagName === 'TEXTAREA' || element.tagName === 'textarea') && element.childNodes.length === 0) { + element.appendChild(document.createTextNode('')); + } else if (element.COMMENT_NODE === 8 && element.textContent === '#') { + pool.push(element.remove()) + } + } + if (!node.children) return + const limit = node.children.length; + for (let i = limit - 1; i > -1; i--) { + if (node.type !== 'head' && typeof selector?.childNodes?.[i] === 'undefined') { + console.error( + `${node.type.toUpperCase()} expected tag ${node.children[i].type.toUpperCase()} to be child at index ${i} but instead found undefined. This error usually happens because of an invalid HTML hierarchy or changes in comparisons after serialization.`, + selector + ) + throw new Error('Virtual DOM does not match the DOM.') + } + hydrateBody(selector.childNodes[i], node.children[i]) + } +} + +function hydrateHead() { + for (const node of client.nextHead) { + if (isFalse(node)) { + node.element = pool.pop() || document.createComment("") + client.head.append(node.element) + } else { + node.element = document.getElementById(node.attributes.id) + } + } + pool = null +} + +export default function hydrate(selector, node) { + hydrateBody(selector, node) + hydrateHead() +} \ No newline at end of file diff --git a/client/index.js b/client/index.js index 2777dd63..887bd72c 100644 --- a/client/index.js +++ b/client/index.js @@ -2,22 +2,23 @@ import state from './state' import element from '../shared/element'; import fragment from '../shared/fragment'; import generateTree from '../shared/generateTree'; -import getProxyableMethods from '../shared/getProxyableMethods'; -import { loadPlugins, usePlugins } from '../shared/plugins'; +import { loadPlugins, useClientPlugins } from '../shared/plugins'; import client from './client'; import context, { generateContext } from './context'; import environment from './environment'; -import instanceProxyHandler from './instanceProxyHandler'; +import instanceProxyHandler, { instanceProxies } from './instanceProxyHandler'; import invoke from './invoke'; -import './liveReload'; import page from './page'; import params, { updateParams } from './params'; import project from './project'; import render from './render'; import rerender from './rerender'; +import hydrate from './hydrate'; import router from './router'; import settings from './settings'; import worker from './worker'; +import klassMap from './klassMap'; +import windowEvent from './windowEvent' context.page = page; context.router = router; @@ -40,18 +41,25 @@ export default class Nullstack { static element = element; static invoke = invoke; static fragment = fragment; - static use = usePlugins('client'); + static use = useClientPlugins; + static klassMap = {} + static context = generateContext({}) static start(Starter) { setTimeout(async () => { window.addEventListener('popstate', () => { router._popState(); }); + if (client.initializer) { + client.initializer = () => element(Starter); + client.update() + return this.context + } client.routes = {}; updateParams(router.url); client.currentInstance = null; client.initializer = () => element(Starter); - client.selector = document.querySelector('#application'); + client.selector = document.getElementById('application'); if (environment.mode === 'spa') { scope.plugins = loadPlugins(scope); worker.online = navigator.onLine; @@ -63,34 +71,33 @@ export default class Nullstack { client.selector = body } else { client.virtualDom = await generateTree(client.initializer(), scope); + hydrate(client.selector, client.virtualDom) + client.currentBody = client.nextBody + client.currentHead = client.nextHead + client.nextBody = {} + client.nextHead = [] context.environment = environment; scope.plugins = loadPlugins(scope); worker.online = navigator.onLine; typeof context.start === 'function' && await context.start(context); client.nextVirtualDom = await generateTree(client.initializer(), scope); - rerender(client.selector); - client.virtualDom = client.nextVirtualDom; - client.nextVirtualDom = null; + rerender(); } client.processLifecycleQueues(); delete state.context; }, 0) - return generateContext({}); + return this.context; } - _self = { - prerendered: false, - initiated: false, - hydrated: false, - terminated: false, - } + prerendered = false + initiated = false + hydrated = false + terminated = false + key = null constructor() { - const methods = getProxyableMethods(this); const proxy = new Proxy(this, instanceProxyHandler); - for (const method of methods) { - this[method] = this[method].bind(proxy); - } + instanceProxies.set(this, proxy) return proxy; } @@ -98,4 +105,40 @@ export default class Nullstack { return false; } +} + +if (module.hot) { + const socket = new WebSocket('ws' + router.base.slice(4) + '/ws'); + window.lastHash + socket.onmessage = async function (e) { + const data = JSON.parse(e.data) + if (data.type === 'NULLSTACK_SERVER_STARTED') { + (window.needsReload || !environment.hot) && window.location.reload() + } else if (data.type === 'hash') { + const newHash = data.data.slice(20) + if (newHash === window.lastHash) { + window.needsReload = true + } else { + window.lastHash = newHash + } + } + }; + Nullstack.updateInstancesPrototypes = function updateInstancesPrototypes(hash, klass) { + for (const key in context.instances) { + const instance = context.instances[key] + if (instance.constructor.hash === hash) { + Object.setPrototypeOf(instance, klass.prototype); + } + } + klassMap[hash] = klass + } + Nullstack.hotReload = function hotReload(klass) { + if (client.skipHotReplacement) { + window.location.reload() + } else { + Nullstack.start(klass); + windowEvent('environment'); + } + } + module.hot.decline() } \ No newline at end of file diff --git a/client/instanceProxyHandler.js b/client/instanceProxyHandler.js index 9e79ca11..fdf83cbd 100644 --- a/client/instanceProxyHandler.js +++ b/client/instanceProxyHandler.js @@ -2,15 +2,21 @@ import client from './client'; import { generateContext } from './context'; import { generateObjectProxy } from './objectProxyHandler'; +export const instanceProxies = new WeakMap() + const instanceProxyHandler = { get(target, name) { if (name === '_isProxy') return true; if (target.constructor[name]?.name === '_invoke') return target.constructor[name].bind(target.constructor) - if (!target[name]?.name?.startsWith('_') && !name.startsWith('_') && typeof (target[name]) == 'function' && name !== 'constructor') { + if (typeof target[name] === 'function' && name !== 'constructor') { + const proxy = instanceProxies.get(target) + if (name.startsWith('_')) { + return target[name].bind(proxy) + } const { [name]: named } = { [name]: (args) => { - const context = generateContext({ ...target._attributes, ...args, self: target._self }); - return target[name](context); + const context = generateContext({ ...target._attributes, ...args }); + return target[name].call(proxy, context); } } return named; @@ -18,7 +24,7 @@ const instanceProxyHandler = { return Reflect.get(...arguments); }, set(target, name, value) { - if (!value?.name?.startsWith('_') && !name.startsWith('_')) { + if (!name.startsWith('_')) { target[name] = generateObjectProxy(name, value); client.update(); } else { diff --git a/client/klassMap.js b/client/klassMap.js new file mode 100644 index 00000000..064689cb --- /dev/null +++ b/client/klassMap.js @@ -0,0 +1,3 @@ +const klassMap = {} + +export default klassMap \ No newline at end of file diff --git a/client/liveReload.js b/client/liveReload.js deleted file mode 100644 index e135a143..00000000 --- a/client/liveReload.js +++ /dev/null @@ -1,24 +0,0 @@ -import worker from './worker' - -let shouldReloadNext = false; -let timer = null; - -function reload() { - if (shouldReloadNext) { - clearInterval(timer); - timer = setTimeout(() => { - location.reload(); - }, 10) - } else { - shouldReloadNext = true; - } -} - -function liveReload() { - const url = worker.api ? `${worker.api.replace('http', 'ws')}` : `${location.protocol.replace('http', 'ws')}//${location.host}` - const socket = new WebSocket(url); - socket.addEventListener('open', reload); - socket.addEventListener('close', liveReload); -} - -liveReload(); \ No newline at end of file diff --git a/client/objectProxyHandler.js b/client/objectProxyHandler.js index 8028e88d..0c544440 100644 --- a/client/objectProxyHandler.js +++ b/client/objectProxyHandler.js @@ -2,37 +2,34 @@ import client from './client'; const objectProxyHandler = { set(target, name, value) { - if(isProxyable(name, value)) { - value._isProxy = true; + if (isProxyable(name, value)) { target[name] = new Proxy(value, this); } else { target[name] = value; } - if(!name.startsWith('_')) { + if (!name.startsWith('_')) { client.update(); } return true; }, get(target, name) { - if(name === '_isProxy') return true; + if (name === '_isProxy') return true; return Reflect.get(...arguments); } } function isProxyable(name, value) { - return ( - !name.startsWith('_') && - value !== null && - typeof(value) === 'object' && - value._isProxy === undefined && - !(value instanceof Date) - ); + if (name.startsWith('_')) return false + const constructor = value?.constructor + if (!constructor) return false + if (value._isProxy) return false + return constructor === Array || constructor === Object } export function generateObjectProxy(name, value) { - if(isProxyable(name, value)) { - if(typeof(value) === 'object') { - for(const key of Object.keys(value)) { + if (isProxyable(name, value)) { + if (typeof (value) === 'object') { + for (const key of Object.keys(value)) { value[key] = generateObjectProxy(key, value[key]); } } diff --git a/client/ref.js b/client/ref.js new file mode 100644 index 00000000..5cb9775f --- /dev/null +++ b/client/ref.js @@ -0,0 +1,27 @@ +const refMap = new WeakMap() + +function setup(attributes, element) { + const object = attributes.ref.object + const property = attributes.ref.property + if (typeof object[property] === 'function') { + setTimeout(() => { + object[property]({ ...attributes, element }) + }, 0) + } else { + object[property] = element + } + const map = refMap.get(attributes.ref.object) || {} + map[attributes.ref.property] = true + refMap.set(attributes.ref.object, map) +} + +export function ref(attributes, element) { + if (!attributes?.ref) return + setup(attributes, element) +} + +export function reref(attributes, element) { + const map = refMap.get(attributes.ref.object) + if (map?.[attributes.ref.property]) return + setup(attributes, element) +} \ No newline at end of file diff --git a/client/render.js b/client/render.js index 5b1eaf60..6ca7e134 100644 --- a/client/render.js +++ b/client/render.js @@ -1,52 +1,57 @@ import { isFalse, isText } from '../shared/nodes'; import { anchorableElement } from './anchorableNode'; +import { eventCallbacks, eventSubjects, generateCallback } from './events' +import { ref } from './ref' +import generateTruthyString from '../shared/generateTruthyString'; export default function render(node, options) { if (isFalse(node) || node.type === 'head') { - return document.createComment(""); + node.element = document.createComment(""); + return node.element } if (isText(node)) { - return document.createTextNode(node); + node.element = document.createTextNode(node.text); + return node.element } const svg = (options && options.svg) || node.type === 'svg'; - let element; if (svg) { - element = document.createElementNS("http://www.w3.org/2000/svg", node.type); + node.element = document.createElementNS("http://www.w3.org/2000/svg", node.type); } else { - element = document.createElement(node.type); + node.element = document.createElement(node.type); } - if (node.instance) { - node.instance._self.element = element; - } + ref(node.attributes, node.element) for (let name in node.attributes) { + if (name === 'debounce') continue if (name === 'html') { - element.innerHTML = node.attributes[name]; - anchorableElement(element); + node.element.innerHTML = node.attributes[name]; + node.head || anchorableElement(node.element); } else if (name.startsWith('on')) { if (node.attributes[name] !== undefined) { - const eventName = name.replace('on', ''); - const key = '_event.' + eventName; - node[key] = (event) => { - if (node.attributes.default !== true) { - event.preventDefault(); - } - node.attributes[name]({ ...node.attributes, event }); - }; - element.addEventListener(eventName, node[key]); + const eventName = name.substring(2); + const callback = generateCallback(node.element, name) + node.element.addEventListener(eventName, callback); + eventCallbacks.set(node.element, callback) + eventSubjects.set(node.element, node.attributes) } } else { - const type = typeof (node.attributes[name]); + let nodeValue; + if ((name === 'class' || name === 'style') && Array.isArray(node.attributes[name])) { + nodeValue = generateTruthyString(node.attributes[name]) + } else { + nodeValue = node.attributes[name] + } + const type = typeof nodeValue; if (type !== 'object' && type !== 'function') { - if (name != 'value' && node.attributes[name] === true) { - element.setAttribute(name, ''); - } else if (name == 'value' || (node.attributes[name] !== false && node.attributes[name] !== null && node.attributes[name] !== undefined)) { - element.setAttribute(name, node.attributes[name]); + if (name != 'value' && nodeValue === true) { + node.element.setAttribute(name, ''); + } else if (name === 'value' || (nodeValue !== false && nodeValue !== null && nodeValue !== undefined)) { + node.element.setAttribute(name, nodeValue); } } } @@ -55,14 +60,14 @@ export default function render(node, options) { if (!node.attributes.html) { for (let i = 0; i < node.children.length; i++) { const child = render(node.children[i], { svg }); - element.appendChild(child); + node.element.appendChild(child); } - if (node.type == 'select') { - element.value = node.attributes.value; + if (node.type === 'select') { + node.element.value = node.attributes.value; } } - return element; + return node.element; } \ No newline at end of file diff --git a/client/rerender.js b/client/rerender.js index 769ffc7c..f67a285b 100644 --- a/client/rerender.js +++ b/client/rerender.js @@ -1,112 +1,126 @@ -import { isFalse, isText } from '../shared/nodes'; +import { isUndefined, isFalse, isText } from '../shared/nodes'; import { anchorableElement } from './anchorableNode'; import client from './client'; import render from './render'; - -export default function rerender(selector, current, next) { - - current = current === undefined ? client.virtualDom : current; - next = next === undefined ? client.nextVirtualDom : next; - - if (next.instance) { - next.instance._self.element = selector; - } - - if (!client.hydrated && selector) { - for (const element of selector.childNodes) { - if (element.tagName && element.tagName.toLowerCase() == 'textarea' && element.childNodes.length == 0) { - element.appendChild(document.createTextNode('')); +import { generateCallback, eventCallbacks, eventSubjects } from './events' +import generateTruthyString from '../shared/generateTruthyString'; +import { reref } from './ref'; + +function updateAttributes(selector, currentAttributes, nextAttributes) { + const attributeNames = Object.keys({ ...currentAttributes, ...nextAttributes }); + for (const name of attributeNames) { + if (name === 'debounce') continue + if (name === 'ref' && nextAttributes?.ref?.property) { + reref(nextAttributes, selector) + } else if (name === 'html') { + if (nextAttributes[name] !== currentAttributes[name]) { + selector.innerHTML = nextAttributes[name]; + anchorableElement(selector); + } + } else if (name === 'checked' || name === 'value') { + if (nextAttributes[name] !== currentAttributes[name] && nextAttributes[name] !== selector[name]) { + selector[name] = nextAttributes[name]; } - if (element.COMMENT_NODE === 8 && element.textContent === '#') { - selector.removeChild(element); + } else if (name.startsWith('on')) { + const eventName = name.substring(2); + if (eventCallbacks.has(selector) && !nextAttributes[name]) { + selector.removeEventListener(eventName, eventCallbacks.get(selector)); + } + if (nextAttributes[name]) { + if (!eventCallbacks.has(selector)) { + const callback = generateCallback(selector, name) + selector.addEventListener(eventName, callback); + eventCallbacks.set(selector, callback) + } + eventSubjects.set(selector, nextAttributes) + } + } else { + let currentValue; + if ((name === 'class' || name === 'style') && Array.isArray(currentAttributes[name])) { + currentValue = generateTruthyString(currentAttributes[name]) + } else { + currentValue = currentAttributes[name] + } + let nextValue; + if ((name === 'class' || name === 'style') && Array.isArray(nextAttributes[name])) { + nextValue = generateTruthyString(nextAttributes[name]) + } else { + nextValue = nextAttributes[name] + } + const type = typeof nextValue; + if (type !== 'object' && type !== 'function') { + if (currentValue !== undefined && nextValue === undefined) { + selector.removeAttribute(name); + } else if (currentValue !== nextValue) { + if (name != 'value' && nextValue === false || nextValue === null || nextValue === undefined) { + selector.removeAttribute(name); + } else if (name != 'value' && nextValue === true) { + selector.setAttribute(name, ''); + } else { + selector.setAttribute(name, nextValue); + } + } } } } +} +function updateHeadChild(current, next) { + if (isUndefined(current) && !isUndefined(next)) { + const nextSelector = render(next); + client.head.append(nextSelector) + return + } + if (!isUndefined(current) && isUndefined(next)) { + current.element.remove() + return + } + next.element = current.element if (isFalse(current) && isFalse(next)) { - return; + return } - - if ((isFalse(current) || isFalse(next)) && current != next) { + if (current.type !== next.type) { const nextSelector = render(next); - return selector.replaceWith(nextSelector); + current.element.replaceWith(nextSelector) + return } + updateAttributes(current.element, current.attributes, next.attributes) +} - if (current.type == 'head' && next.type == 'head') { - return; +function updateHeadChildren(currentChildren, nextChildren) { + const limit = Math.max(currentChildren.length, nextChildren.length); + for (let i = 0; i < limit; i++) { + updateHeadChild(currentChildren[i], nextChildren[i]) } +} - if (current.type == 'head' || next.type == 'head') { - const nextSelector = render(next); - return selector.replaceWith(nextSelector); +function _rerender(current, next) { + + const selector = current.element + next.element = current.element + + if (isFalse(current) && isFalse(next)) { + return; } if (current.type !== next.type) { const nextSelector = render(next); - return selector.replaceWith(nextSelector); + selector.replaceWith(nextSelector); + return } if (isText(current) && isText(next)) { - if (current != next) { - selector.nodeValue = next; + if (current.text !== next.text) { + selector.textContent = next.text; } return; } - if (current.type === next.type) { - - const attributeNames = Object.keys({ ...current.attributes, ...next.attributes }); - for (const name of attributeNames) { - if (name === 'html') { - if (next.attributes[name] !== current.attributes[name]) { - selector.innerHTML = next.attributes[name]; - } - anchorableElement(selector); - } else if (name === 'checked') { - if (next.attributes[name] !== selector.value) { - selector.checked = next.attributes[name]; - } - } else if (name === 'value') { - if (next.attributes[name] !== selector.value) { - selector.value = next.attributes[name]; - } - } else if (name.startsWith('on')) { - const eventName = name.replace('on', ''); - const key = '_event.' + eventName; - selector.removeEventListener(eventName, current[key]); - if (next.attributes[name]) { - next[key] = (event) => { - if (next.attributes.default !== true) { - event.preventDefault(); - } - next.attributes[name]({ ...next.attributes, event }); - }; - selector.addEventListener(eventName, next[key]); - } - } else { - const type = typeof (next.attributes[name]); - if (type !== 'object' && type !== 'function') { - if (current.attributes[name] !== undefined && next.attributes[name] === undefined) { - selector.removeAttribute(name); - } else if (current.attributes[name] !== next.attributes[name]) { - if (name != 'value' && next.attributes[name] === false || next.attributes[name] === null || next.attributes[name] === undefined) { - selector.removeAttribute(name); - } else if (name != 'value' && next.attributes[name] === true) { - selector.setAttribute(name, ''); - } else { - selector.setAttribute(name, next.attributes[name]); - } - } - } - } - } - - if (next.attributes.html) return; - + if (!next.attributes.html) { const limit = Math.max(current.children.length, next.children.length); if (next.children.length > current.children.length) { for (let i = 0; i < current.children.length; i++) { - rerender(selector.childNodes[i], current.children[i], next.children[i]); + _rerender(current.children[i], next.children[i]); } for (let i = current.children.length; i < next.children.length; i++) { const nextSelector = render(next.children[i]); @@ -114,33 +128,30 @@ export default function rerender(selector, current, next) { } } else if (current.children.length > next.children.length) { for (let i = 0; i < next.children.length; i++) { - rerender(selector.childNodes[i], current.children[i], next.children[i]); + _rerender(current.children[i], next.children[i]); } for (let i = current.children.length - 1; i >= next.children.length; i--) { - selector.removeChild(selector.childNodes[i]); + selector.childNodes[i].remove() } } else { for (let i = limit - 1; i > -1; i--) { - if (typeof selector.childNodes[i] === 'undefined') { - console.error( - `${current.type.toUpperCase()} expected tag ${current.children[i].type.toUpperCase()} to be child at index ${i} but instead found undefined. This error usually happens because of an invalid HTML hierarchy or changes in comparisons after serialization.`, - selector - ) - throw new Error('Virtual DOM does not match the DOM.') - return; - } - rerender(selector.childNodes[i], current.children[i], next.children[i]); + _rerender(current.children[i], next.children[i]); } } - - if (next.type == 'textarea') { - selector.value = next.children.join(""); - } - - if (next.type == 'select') { - selector.value = next.attributes.value; - } - } -} \ No newline at end of file + updateAttributes(selector, current.attributes, next.attributes) + +} + +export default function rerender() { + _rerender(client.virtualDom, client.nextVirtualDom) + updateAttributes(client.body, client.currentBody, client.nextBody) + updateHeadChildren(client.currentHead, client.nextHead) + client.virtualDom = client.nextVirtualDom + client.nextVirtualDom = null + client.currentBody = client.nextBody + client.nextBody = {} + client.currentHead = client.nextHead + client.nextHead = [] +} diff --git a/client/router.js b/client/router.js index 5b666376..28e2415d 100644 --- a/client/router.js +++ b/client/router.js @@ -28,8 +28,9 @@ class Router { } async _update(target, push) { - this.previous = this.url; const { url, path, hash, urlWithHash } = extractLocation(target); + if (url === this._url && this._hash === hash) return + this.previous = this.url; clearTimeout(redirectTimer); redirectTimer = setTimeout(async () => { page.status = 200; @@ -63,16 +64,12 @@ class Router { } async _redirect(target) { - if (target.startsWith('http')) { - return (window.location.href = target); - } - const { url, hash, urlWithHash } = extractLocation(target); - if (url !== this._url || this._hash !== hash) { - await this._update(urlWithHash, true); - } - if (!hash) { - window.scroll(0, 0); + if (/^(\w+:|\/\/)([^.]+.)/.test(target)) { + return window.location.href = target; } + const absoluteUrl = new URL(target, document.baseURI); + await this._update(absoluteUrl.pathname + absoluteUrl.search + absoluteUrl.hash, true); + window.scroll(0, 0) } get url() { @@ -91,6 +88,12 @@ class Router { this._redirect(target + window.location.search); } + get base() { + if (this._base) return this._base + this._base = new URL(document.querySelector('[rel="canonical"]').href).origin + return this._base + } + } const router = new Router(); diff --git a/loaders/add-source-to-node.js b/loaders/add-source-to-node.js index 8a4ebd67..48d89ce5 100644 --- a/loaders/add-source-to-node.js +++ b/loaders/add-source-to-node.js @@ -1,36 +1,10 @@ -module.exports = function(source) { - let match; +module.exports = function (source) { let tags = source.split('<'); - source = tags.map((tag) => { - match = tag.match(/bind\=\{(.*?)\}/); - if(match && tag.indexOf('source={') == -1) { - let [a, b] = match[1].split(/\.(?=[^\.]+$)/); - if(!b) { - b = a; - a = ''; - } - if(b.indexOf('[') > -1) { - const [ref, index] = b.split('['); - if(a) { - a = [a, ref].join('.'); - } else { - a = ref; - } - b = '{' + index.replace(']', '}'); - } else { - b = `"${b}"`; - } - return tag.replace(match[0], `source={${a}} bind=${b}`); - } - return tag; - }).join('<'); - tags = source.split('<'); - source = tags.map((tag) => { + return tags.map((tag) => { match = tag.match(/\ on([a-z]*?)\=\{(.*?)\}/); - if(match && tag.indexOf('source={') == -1) { + if (match && tag.indexOf('source={') === -1) { return tag.substring(0, match.index) + ' source={this}' + tag.substring(match.index); } return tag; }).join('<'); - return source; } \ No newline at end of file diff --git a/loaders/inject-hmr.js b/loaders/inject-hmr.js new file mode 100644 index 00000000..8d325d03 --- /dev/null +++ b/loaders/inject-hmr.js @@ -0,0 +1,39 @@ +const parse = require('@babel/parser').parse; +const traverse = require("@babel/traverse").default; + +module.exports = function (source) { + const ast = parse(source, { + sourceType: 'module', + plugins: ['classProperties', 'jsx'] + }); + let klassName + let klassPath + traverse(ast, { + MemberExpression(path) { + if (path.node.property.name === 'start' && path.node.object && path.node.object.name === 'Nullstack') { + klassName = path.parent.arguments[0].name + } + } + }); + if (!klassName) return source + traverse(ast, { + ImportDeclaration(path) { + if (path.node.specifiers[0].local.name === klassName) { + klassPath = path.node.source.extra.rawValue + } + } + }); + + return source + ` + if (module.hot) { + if (window.needsClientReload) { + window.location.reload() + } + module.hot.accept() + window.needsClientReload = true + module.hot.accept('${klassPath}', () => { + Nullstack.hotReload(${klassName}) + }) + } + ` +} \ No newline at end of file diff --git a/loaders/register-inner-components.js b/loaders/register-inner-components.js index 7e893a3f..fa5f32c2 100644 --- a/loaders/register-inner-components.js +++ b/loaders/register-inner-components.js @@ -11,23 +11,25 @@ module.exports = function (source) { traverse(ast, { ClassMethod(path) { if (path.node.key.name.startsWith('render')) { - traverse(path.node, { - JSXIdentifier(subpath) { - if (/^[A-Z]/.test(subpath.node.name)) { - if (!path.scope.hasBinding(subpath.node.name)) { - const start = path.node.body.body[0].start; - if (!positions.includes(start)) { - positions.push(start); - } - if (!injections[start]) { - injections[start] = []; - } - if (!injections[start].includes(subpath.node.name)) { - injections[start].push(subpath.node.name); - } + function identify(subpath) { + if (/^[A-Z]/.test(subpath.node.name)) { + if (!path.scope.hasBinding(subpath.node.name)) { + const start = path.node.body.body[0].start; + if (!positions.includes(start)) { + positions.push(start); + } + if (!injections[start]) { + injections[start] = []; + } + if (!injections[start].includes(subpath.node.name)) { + injections[start].push(subpath.node.name); } } - }, + } + } + traverse(path.node, { + JSXIdentifier: identify, + Identifier: identify, }, path.scope, path); } } diff --git a/loaders/register-static-from-server.js b/loaders/register-static-from-server.js index d3d932dd..c299c3c5 100644 --- a/loaders/register-static-from-server.js +++ b/loaders/register-static-from-server.js @@ -28,9 +28,6 @@ module.exports = function (source) { }); if (!hasClass) return source; let output = source.substring(0, klassEnd); - for (const methodName of methodNames) { - output += `${methodName} = Nullstack.invoke('${methodName}');\n` - } output += source.substring(klassEnd); for (const methodName of methodNames) { output += `\nNullstack.registry["${hash}.${methodName}"] = ${klassName}.${methodName};` @@ -38,5 +35,7 @@ module.exports = function (source) { } output += `\nNullstack.registry["${hash}"] = ${klassName};` output += `\nNullstack.registry["${legacyHash}"] = ${klassName};` + output += `\n${klassName}.hash = "${hash}";` + output += `\n${klassName}.bindStaticFunctions(${klassName});` return output; } \ No newline at end of file diff --git a/loaders/remove-static-from-client.js b/loaders/remove-static-from-client.js index 1aefa695..ea215e2c 100644 --- a/loaders/remove-static-from-client.js +++ b/loaders/remove-static-from-client.js @@ -6,6 +6,7 @@ module.exports = function removeStaticFromClient(source) { const id = this.resourcePath.replace(this.rootContext, '') const hash = crypto.createHash('md5').update(id).digest("hex"); let hashPosition; + let klassName; const injections = {}; const positions = []; const ast = parse(source, { @@ -13,6 +14,9 @@ module.exports = function removeStaticFromClient(source) { plugins: ['classProperties', 'jsx'] }); traverse(ast, { + ClassDeclaration(path) { + klassName = path.node.id.name; + }, ClassBody(path) { const start = path.node.body[0].start; hashPosition = start; @@ -50,5 +54,9 @@ module.exports = function removeStaticFromClient(source) { outputs.push(`static hash = '${hash}';\n\n `); } } - return outputs.reverse().join(''); + let newSource = outputs.reverse().join('') + if (klassName) { + newSource += `\nif (module.hot) { Nullstack.updateInstancesPrototypes(${klassName}.hash, ${klassName}) }`; + } + return newSource } \ No newline at end of file diff --git a/loaders/transform-node-ref.js b/loaders/transform-node-ref.js new file mode 100644 index 00000000..8be96e23 --- /dev/null +++ b/loaders/transform-node-ref.js @@ -0,0 +1,49 @@ +const parse = require('@babel/parser').parse; +const traverse = require("@babel/traverse").default; + +const attributes = ['ref', 'bind'] + +module.exports = function removeStaticFromClient(source) { + const ast = parse(source, { + sourceType: 'module', + plugins: ['classProperties', 'jsx', 'typescript'] + }); + const refs = [] + traverse(ast, { + JSXAttribute(path) { + const attribute = path.node.name.name + if (attributes.includes(attribute)) { + const expression = path.node.value.expression + if (expression.type !== 'Identifier') { + const object = expression.object + const property = expression.property + const refObject = source.slice(object.start, object.end) + let refProperty = source.slice(property.start, property.end) + if (property.type === 'Identifier' && !expression.computed) { + refProperty = `'${refProperty}'` + } + replacement = `${attribute}={{object: ${refObject}, property: ${refProperty}}}` + refs.push({ + start: path.node.start, + end: path.node.end, + replacement + }) + } + } + } + }); + if (refs.length === 0) return source + const sources = [] + for (let i = 0; i <= refs.length; i++) { + const prev = refs[i - 1] + const current = refs[i] + const start = prev ? prev.end : 0 + const end = current ? current.start : undefined + const before = source.slice(start, end) + sources.push(before) + if (current) { + sources.push(current.replacement) + } + } + return sources.join('') +} \ No newline at end of file diff --git a/package.json b/package.json index f18654de..f1006209 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nullstack", - "version": "0.15.10", + "version": "0.16.0", "description": "Full-stack Javascript Components for one-dev armies", "main": "nullstack.js", "author": "Mortaro", @@ -12,11 +12,12 @@ }, "types": "./types/index.d.ts", "dependencies": { - "@swc/core": "1.2.179", "@babel/parser": "7.17.12", "@babel/traverse": "7.17.12", + "@swc/core": "1.2.179", "body-parser": "1.20.0", "commander": "8.3.0", + "copy-webpack-plugin": "^11.0.0", "cors": "2.8.5", "css-loader": "6.7.1", "dotenv": "8.6.0", @@ -24,12 +25,13 @@ "fs-extra": "10.1.0", "mini-css-extract-plugin": "2.6.0", "node-fetch": "2.6.7", - "nodemon-webpack-plugin": "4.7.1", + "nodemon-webpack-plugin": "^4.8.1", "sass": "1.51.0", "sass-loader": "8.0.2", "swc-loader": "0.2.1", "terser-webpack-plugin": "5.3.1", "webpack": "5.72.1", + "webpack-dev-server": "4.9.0", "ws": "7.5.7" } -} \ No newline at end of file +} diff --git a/plugins/anchorable.js b/plugins/anchorable.js index d3f92925..8769b4f9 100644 --- a/plugins/anchorable.js +++ b/plugins/anchorable.js @@ -1,3 +1,5 @@ +import noop from '../shared/noop' + function match(node) { return ( node && @@ -8,21 +10,9 @@ function match(node) { ) } -function transform({ node, router }) { +function transform({ node }) { if (!match(node)) return - const originalEvent = node.attributes.onclick - node.attributes.default = true - node.attributes.onclick = ({ event }) => { - if (!event.ctrlKey && !event.shiftKey && !event.altKey && !event.metaKey) { - event.preventDefault() - router.url = node.attributes.href - } - if (originalEvent) { - setTimeout(() => { - originalEvent({ ...node.attributes, event }) - }, 0) - } - } + node.attributes.onclick ??= noop } export default { transform, client: true } diff --git a/plugins/bindable.js b/plugins/bindable.js index 7d7174f2..e0dc0827 100644 --- a/plugins/bindable.js +++ b/plugins/bindable.js @@ -1,56 +1,30 @@ -function attachEvent(node) { - const target = node.attributes.source; - let eventName = 'oninput'; - let valueName = 'value'; - if (node.attributes.type === 'checkbox' || node.attributes.type === 'radio') { - eventName = 'onclick'; - valueName = 'checked'; - } else if (node.type !== 'input' && node.type !== 'textarea') { - eventName = 'onchange'; - } - const originalEvent = node.attributes[eventName]; - node.attributes[eventName] = ({ event, value }) => { - if (valueName == 'checked') { - target[node.attributes.bind] = event.target[valueName]; - } else if (target[node.attributes.bind] === true || target[node.attributes.bind] === false) { - target[node.attributes.bind] = event ? (event.target[valueName] == 'true') : value; - } else if (typeof target[node.attributes.bind] === 'number') { - target[node.attributes.bind] = parseFloat(event ? event.target[valueName] : value) || 0; - } else { - target[node.attributes.bind] = event ? event.target[valueName] : value; - } - if (originalEvent !== undefined) { - setTimeout(() => { - originalEvent({ ...node.attributes, event, value }); - }, 0); - } - } -} +import noop from '../shared/noop' function match(node) { - return ( - node !== undefined && - node.attributes !== undefined && - node.attributes.bind !== undefined && - node.attributes.source !== undefined - ) + return node?.attributes?.bind !== undefined } function transform({ node, environment }) { if (!match(node)) return; - const target = node.attributes.source; + const object = node.attributes.bind.object ?? {}; + const property = node.attributes.bind.property; if (node.type === 'textarea') { - node.children = [target[node.attributes.bind]]; + node.children = [object[property]]; } else if (node.type === 'input' && node.attributes.type === 'checkbox') { - node.attributes.checked = target[node.attributes.bind]; + node.attributes.checked = object[property]; } else { - node.attributes.value = target[node.attributes.bind] || ''; + node.attributes.value = object[property] ?? ''; } node.attributes.name = node.attributes.name || node.attributes.bind; - if (environment.client) { - attachEvent(node); + if (node.attributes.type === 'checkbox' || node.attributes.type === 'radio') { + node.attributes.onclick ??= noop; + } else if (node.type !== 'input' && node.type !== 'textarea') { + node.attributes.onchange ??= noop; + } else { + node.attributes.oninput ??= noop; + } } } -export default { transform, client: true, server: true } \ No newline at end of file +export default { transform, client: true, server: true } diff --git a/plugins/datable.js b/plugins/datable.js deleted file mode 100644 index a4a78e61..00000000 --- a/plugins/datable.js +++ /dev/null @@ -1,25 +0,0 @@ -import {camelize, kebabize} from '../shared/string'; - -function match(node) { - return ( - node && - node.attributes !== undefined - ) -} - -function transform({node}) { - if(!match(node)) return; - node.attributes.data = node.attributes.data || {}; - for(const attribute in node.attributes) { - if(attribute.startsWith('data-')) { - const key = camelize(attribute.slice(5)); - node.attributes.data[key] = node.attributes[attribute]; - } - } - for(const key in node.attributes.data) { - const attribute = 'data-' + kebabize(key); - node.attributes[attribute] = node.attributes.data[key]; - } -} - -export default { transform, client: true, server: true } \ No newline at end of file diff --git a/plugins/objectable.js b/plugins/objectable.js deleted file mode 100644 index bc6265f2..00000000 --- a/plugins/objectable.js +++ /dev/null @@ -1,23 +0,0 @@ - - -function match(node) { - return ( - node && - node.attributes !== undefined - ) -} - -function transform({node}) { - if(!match(node)) return; - for(const attribute in node.attributes) { - if(attribute.startsWith('on') && typeof(node.attributes[attribute]) === 'object') { - const target = node.attributes.source; - const object = node.attributes[attribute]; - node.attributes[attribute] = (function() { - Object.assign(target, object); - }).bind(target); - } - } -} - -export default { transform, client: true } \ No newline at end of file diff --git a/plugins/routable.js b/plugins/routable.js index 7f58a5cd..a8c20737 100644 --- a/plugins/routable.js +++ b/plugins/routable.js @@ -8,33 +8,25 @@ function erase(node) { function match(node) { return ( - node && + node && node.attributes !== undefined && node.attributes.route !== undefined ) } -function load({router}) { +function load({ router }) { router._routes = {}; - if(!router._oldSegments) { - router._oldSegments = {}; - router._newSegments = {}; - } else { - router._oldSegments = router._newSegments; - router._newSegments = {}; - } } -function transform({node, depth, router}) { - if(!match(node)) return; - const routeDepth = depth.slice(0, -1).join('.'); - if(router._routes[routeDepth] !== undefined) { +function transform({ node, depth, router }) { + if (!match(node)) return; + const routeDepth = depth.slice(0, depth.lastIndexOf('-')) + if (router._routes[routeDepth] !== undefined) { erase(node); } else { const params = routeMatches(router.url, node.attributes.route); - if(params) { + if (params) { router._routes[routeDepth] = true; - router._newSegments[routeDepth] = params; Object.assign(router._segments, params); } else { erase(node); diff --git a/scripts/index.js b/scripts/index.js index a5ee257f..34034d1f 100755 --- a/scripts/index.js +++ b/scripts/index.js @@ -2,71 +2,148 @@ const { program } = require('commander'); const { version } = require('../package.json'); -let lastTrace = ''; -let compilingIndex = 1; - const webpack = require('webpack'); const path = require('path'); -const { existsSync } = require('fs'); +const { existsSync, rmdirSync, readdir, unlink } = require('fs'); const customConfig = path.resolve(process.cwd(), './webpack.config.js'); const config = existsSync(customConfig) ? require(customConfig) : require('../webpack.config'); +const dotenv = require('dotenv') +const fetch = require('node-fetch') -const buildModes = ['ssg', 'spa', 'ssr'] +function getConfig(options) { + return config.map((env) => env(null, options)) +} function getCompiler(options) { - const configs = config.map((env) => env(null, options)) - return webpack(configs) + return webpack(getConfig(options)) } -function logCompiling(showCompiling) { - if (!showCompiling) return; - console.log(" βš™οΈ Compiling changes..."); +function loadEnv(env) { + let envPath = '.env' + if (env) { + envPath += `.${process.env.NULLSTACK_ENVIRONMENT_NAME}` + } + dotenv.config({ path: envPath }) } -function logTrace(stats, showCompiling) { - if (stats.hasErrors()) { - const response = stats.toJson('errors-only', { colors: true }) - const error = response.errors[0] || response.children[0].errors[0]; - const { moduleName: file, message } = error - const [loader, ...trace] = message.split('\n'); - if (loader.indexOf('/nullstack/loaders') === -1) trace.unshift(loader) - const currentTrace = trace.join(' '); - if (lastTrace === currentTrace) return; - lastTrace = currentTrace; - logCompiling(showCompiling); - console.log(` πŸ’₯️ There is an error preventing compilation in \x1b[31m${file}\x1b[0m`); - for (const line of trace) { - console.log('\x1b[31m%s\x1b[0m', ' ' + line.trim()); +function getFreePorts() { + return new Promise((resolve, reject) => { + const app1 = require('express')(); + const app2 = require('express')(); + const server1 = app1.listen(0, () => { + const server2 = app2.listen(0, () => { + const ports = [ + server1.address().port, + server2.address().port + ] + server1.close() + server2.close() + resolve(ports) + }); + }); + }) +} + +function getPort(port) { + return port || process.env['NULLSTACK_SERVER_PORT'] || process.env['PORT'] || 3000 +} + +function clearOutput(outputPath) { + if (!existsSync(outputPath)) return + readdir(outputPath, (err, files) => { + if (err) throw err; + for (const file of files) { + if (file === '.cache') continue; + unlink(path.join(outputPath, file), err => { + if (err) throw err; + }); } - console.log(); - compilingIndex = 0; - return - } - compilingIndex++; - if (compilingIndex % 2 === 0) { - logCompiling(showCompiling); - compilingIndex = 0; - } - lastTrace = ''; + }); } -function start({ input, port, env, output, mode = 'ssr' }) { - const environment = 'development'; - const compiler = getCompiler({ environment, input }); - if (port) { - process.env['NULLSTACK_SERVER_PORT'] = port; - } - if (env) { - process.env['NULLSTACK_ENVIRONMENT_NAME'] = env; - } +async function start({ input, port, env, mode = 'spa', cold, disk }) { + const environment = 'development' console.log(` πŸš€οΈ Starting your application in ${environment} mode...`); - console.log(); - compiler.watch({}, (error, stats) => { - logTrace(stats, true) - if (!stats.hasErrors() && mode !== 'ssr') { - require(`../builders/${mode}`)({ output, environment }); - }; - }); + loadEnv(env) + const WebpackDevServer = require('webpack-dev-server'); + const { setLogLevel } = require('webpack/hot/log') + setLogLevel('none') + process.env['NULLSTACK_ENVIRONMENT_MODE'] = mode + process.env['NULLSTACK_SERVER_PORT'] = getPort(port) + const ports = await getFreePorts() + process.env['NULSTACK_SERVER_PORT_YOU_SHOULD_NOT_CARE_ABOUT'] = ports[0] + process.env['NULSTACK_SERVER_SOCKET_PORT_YOU_SHOULD_NOT_CARE_ABOUT'] = ports[1] + process.env['NULLSTACK_ENVIRONMENT_HOT'] = (!cold).toString() + process.env['NULLSTACK_ENVIRONMENT_DISK'] = (!!disk).toString() + if (!process.env['NULLSTACK_PROJECT_DOMAIN']) process.env['NULLSTACK_PROJECT_DOMAIN'] = 'localhost' + if (!process.env['NULLSTACK_WORKER_PROTOCOL']) process.env['NULLSTACK_WORKER_PROTOCOL'] = 'http' + const target = `${process.env['NULLSTACK_WORKER_PROTOCOL']}://${process.env['NULLSTACK_PROJECT_DOMAIN']}:${process.env['NULSTACK_SERVER_PORT_YOU_SHOULD_NOT_CARE_ABOUT']}` + const writeToDisk = disk ? true : (path) => path.includes('server') + const devServerOptions = { + hot: 'only', + open: false, + host: process.env['NULLSTACK_PROJECT_DOMAIN'], + devMiddleware: { + index: false, + stats: 'none', + writeToDisk, + }, + client: { + overlay: { errors: true, warnings: false }, + logging: 'none', + progress: false, + reconnect: true, + webSocketURL: `${process.env['NULLSTACK_WORKER_PROTOCOL'].replace('http', 'ws')}://${process.env['NULLSTACK_PROJECT_DOMAIN']}:${process.env['NULLSTACK_SERVER_PORT']}/ws` + }, + proxy: { + context: () => true, + target, + proxyTimeout: 10 * 60 * 1000, + timeout: 10 * 60 * 1000, + }, + setupMiddlewares: (middlewares, devServer) => { + if (!devServer) { + throw new Error('webpack-dev-server is not defined'); + } + middlewares.unshift(async (req, res, next) => { + if (req.originalUrl.indexOf('.hot-update.') === -1) { + if (req.originalUrl.startsWith('/nullstack/')) { + console.log(` βš™οΈ [${req.method}] ${req.originalUrl}`) + } else { + console.log(` πŸ•ΈοΈ [${req.method}] ${req.originalUrl}`) + } + } + async function waitForServer() { + if (req.originalUrl.includes('.')) { + return next() + } + try { + await fetch(`${target}${req.originalUrl}`) + next() + } catch (error) { + if (error.message.includes('ECONNREFUSED')) { + setTimeout(waitForServer, 100) + } else { + throw error + } + } + } + waitForServer() + }); + return middlewares; + }, + webSocketServer: require.resolve('./socket'), + port: process.env['NULLSTACK_SERVER_PORT'] + }; + const compiler = getCompiler({ environment, input, disk }); + clearOutput(compiler.compilers[0].outputPath) + const server = new WebpackDevServer(devServerOptions, compiler); + const portChecker = require('express')().listen(process.env['NULLSTACK_SERVER_PORT'], () => { + portChecker.close() + server.startCallback(() => { + console.log('\x1b[36m%s\x1b[0m', ` βœ…οΈ Your application is ready at http://${process.env['NULLSTACK_PROJECT_DOMAIN']}:${process.env['NULLSTACK_SERVER_PORT']}\n`); + }); + }) } function build({ input, output, cache, env, mode = 'ssr' }) { @@ -77,7 +154,10 @@ function build({ input, output, cache, env, mode = 'ssr' }) { } console.log(` πŸš€οΈ Building your application in ${mode} mode...`); compiler.run((error, stats) => { - logTrace(stats, false); + if (stats.hasErrors()) { + console.log(stats.toString({ colors: true })) + process.exit(1); + } if (stats.hasErrors()) process.exit(1); require(`../builders/${mode}`)({ output, cache, environment }); }); @@ -87,11 +167,12 @@ program .command('start') .alias('s') .description('Start application in development environment') - .addOption(new program.Option('-m, --mode ', 'Build production bundles').choices(buildModes)) + .addOption(new program.Option('-m, --mode ', 'Build production bundles').choices(['ssr', 'spa'])) .option('-p, --port ', 'Port number to run the server') .option('-i, --input ', 'Path to project that will be started') - .option('-o, --output ', 'Path to build output folder') .option('-e, --env ', 'Name of the environment file that should be loaded') + .option('-d, --disk', 'Write files to disk') + .option('-c, --cold', 'Disable hot module replacement') .helpOption('-h, --help', 'Learn more about this command') .action(start) @@ -99,7 +180,7 @@ program .command('build') .alias('b') .description('Build application for production environment') - .addOption(new program.Option('-m, --mode ', 'Build production bundles').choices(buildModes)) + .addOption(new program.Option('-m, --mode ', 'Build production bundles').choices(['ssr', 'spa', 'ssg'])) .option('-i, --input ', 'Path to project that will be built') .option('-o, --output ', 'Path to build output folder') .option('-c, --cache', 'Cache build results in .production folder') diff --git a/scripts/socket.js b/scripts/socket.js new file mode 100644 index 00000000..b1960028 --- /dev/null +++ b/scripts/socket.js @@ -0,0 +1,70 @@ +"use strict"; + +const WebSocket = require("ws"); +const BaseServer = require("webpack-dev-server/lib/servers/BaseServer"); + +module.exports = class WebsocketServer extends BaseServer { + constructor(server) { + super(server); + + const options = { + ...(this.server.options.webSocketServer).options, + clientTracking: false, + port: process.env['NULSTACK_SERVER_SOCKET_PORT_YOU_SHOULD_NOT_CARE_ABOUT'] + }; + + this.implementation = new WebSocket.Server(options); + + this.server.server.on("upgrade", (req, sock, head) => { + if (!this.implementation.shouldHandle(req)) { + return; + } + this.implementation.handleUpgrade(req, sock, head, (connection) => { + this.implementation.emit("connection", connection, req); + }); + }); + + this.implementation.on("error", (err) => { + this.server.logger.error(err.message); + }); + + const interval = setInterval(() => { + this.clients.forEach((client) => { + if (client.isAlive === false) { + client.terminate(); + + return; + } + + client.isAlive = false; + client.ping(() => { }); + }); + }, 1000); + + this.implementation.on("connection", (client) => { + this.clients.push(client); + + client.isAlive = true; + + client.on("message", (data) => { + if (data === '{"type":"NULLSTACK_SERVER_STARTED"}') { + this.clients.forEach((client) => { + client.send('{"type":"NULLSTACK_SERVER_STARTED"}') + }); + } + }) + + client.on("pong", () => { + client.isAlive = true; + }); + + client.on("close", () => { + this.clients.splice(this.clients.indexOf(client), 1); + }); + }); + + this.implementation.on("close", () => { + clearInterval(interval); + }); + } +}; diff --git a/server/environment.js b/server/environment.js index 005dcb23..7f74e2b4 100644 --- a/server/environment.js +++ b/server/environment.js @@ -1,5 +1,3 @@ -//import files from './files'; - const environment = { client: false, server: true }; environment.development = __dirname.indexOf('.development') > -1; @@ -11,6 +9,10 @@ environment.key = "{{NULLSTACK_ENVIRONMENT_KEY}}" environment.name = process.env.NULLSTACK_ENVIRONMENT_NAME || ''; +if (environment.development) { + environment.hot = process.env.NULLSTACK_ENVIRONMENT_HOT === 'true' +} + Object.freeze(environment); export default environment; \ No newline at end of file diff --git a/server/index.js b/server/index.js index 8c418b5e..87743ad0 100644 --- a/server/index.js +++ b/server/index.js @@ -3,18 +3,19 @@ import { normalize } from 'path'; import element from '../shared/element'; import fragment from '../shared/fragment'; import getProxyableMethods from '../shared/getProxyableMethods'; -import { usePlugins } from '../shared/plugins'; +import { useServerPlugins } from '../shared/plugins'; import context from './context'; import environment from './environment'; import generator from './generator'; import instanceProxyHandler from './instanceProxyHandler'; -import invoke from './invoke'; import project from './project'; import registry from './registry'; import secrets from './secrets'; import server from './server'; import settings from './settings'; import worker from './worker'; +import reqres from './reqres' +import { generateContext } from './context'; globalThis.window = {} @@ -31,9 +32,39 @@ class Nullstack { static registry = registry; static element = element; - static invoke = invoke; static fragment = fragment; - static use = usePlugins('server'); + static use = useServerPlugins; + + static bindStaticFunctions(klass) { + let parent = klass + while (parent.name !== 'Nullstack') { + const props = Object.getOwnPropertyNames(parent) + for (const prop of props) { + const underscored = prop.startsWith('_') + if (typeof klass[prop] === 'function') { + if (!underscored && !registry[`${parent.hash}.${prop}`]) { + return + } + const propName = `__NULLSTACK_${prop}` + if (!klass[propName]) { + klass[propName] = klass[prop] + } + function _invoke(...args) { + if (underscored) { + return klass[propName].call(klass, ...args); + } + const params = args[0] || {} + const { request, response } = reqres + const context = generateContext({ request, response, ...params }); + return klass[propName].call(klass, context); + } + klass[prop] = _invoke + klass.prototype[prop] = _invoke + } + } + parent = Object.getPrototypeOf(parent) + } + } static start(Starter) { if (this.name.indexOf('Nullstack') > -1) { @@ -43,16 +74,13 @@ class Nullstack { } } - _self = { - prerendered: true, - initiated: false, - hydrated: false, - terminated: false, - } + prerendered = true + initiated = false + hydrated = false + terminated = false + key = null - constructor(scope) { - this._request = () => scope.request; - this._response = () => scope.response; + constructor() { const methods = getProxyableMethods(this); const proxy = new Proxy(this, instanceProxyHandler); for (const method of methods) { @@ -64,9 +92,15 @@ class Nullstack { toJSON() { const serialized = {}; for (const name of Object.getOwnPropertyNames(this)) { - if (typeof (this[name]) !== 'function' && !name.startsWith('_') && name !== 'attributes') { - serialized[name] = this[name]; - } + if (name === 'prerendered') continue + if (name === 'initiated') continue + if (name === 'hydrated') continue + if (name === 'terminated') continue + if (name === 'key') continue + if (name === '_attributes') continue + if (name === '_scope') continue + if (typeof this[name] === 'function') continue + serialized[name] = this[name]; } return serialized; } diff --git a/server/instanceProxyHandler.js b/server/instanceProxyHandler.js index 0686147f..3daf4e60 100644 --- a/server/instanceProxyHandler.js +++ b/server/instanceProxyHandler.js @@ -1,8 +1,11 @@ const instanceProxyHandler = { get(target, name) { - if (!target[name]?.name?.startsWith('_') && !name.startsWith('_') && typeof (target[name]) == 'function' && name !== 'constructor') { + if (typeof target[name] === 'function' && name !== 'constructor') { + if (name.startsWith('_')) { + return target[name].bind(target) + } return (args) => { - const context = target._scope.generateContext({ ...target._attributes, ...args, self: target._self }); + const context = target._scope.generateContext({ ...target._attributes, ...args }); return target[name](context); } } diff --git a/server/invoke.js b/server/invoke.js deleted file mode 100644 index afbc5848..00000000 --- a/server/invoke.js +++ /dev/null @@ -1,10 +0,0 @@ -import {generateContext} from './context'; - -export default function invoke(name) { - return async function _invoke(params = {}) { - const request = this._request(); - const response = this._response(); - const context = generateContext({request, response, ...params}); - return await this.constructor[name](context); - } -} \ No newline at end of file diff --git a/server/liveReload.js b/server/liveReload.js deleted file mode 100644 index 24263a61..00000000 --- a/server/liveReload.js +++ /dev/null @@ -1,31 +0,0 @@ -import WebSocket from 'ws'; - -export default function liveReload(server) { - - function noop() {} - - function heartbeat() { - this.isAlive = true; - } - - const wss = new WebSocket.Server({server}); - - wss.on('connection', function connection(ws) { - ws.isAlive = true; - ws.on('pong', heartbeat); - ws.on('message', (data) => { - wss.clients.forEach(function each(client) { - client.send(data); - }); - }); - }); - - setInterval(function ping() { - wss.clients.forEach(function each(ws) { - if (ws.isAlive === false) return ws.terminate(); - ws.isAlive = false; - ws.ping(noop); - }); - }, 30000); - -} \ No newline at end of file diff --git a/server/prerender.js b/server/prerender.js index f8a3513a..b2905535 100644 --- a/server/prerender.js +++ b/server/prerender.js @@ -31,12 +31,15 @@ export async function prerender(request, response) { scope.body = ''; scope.context = context; scope.generateContext = generateContext(context); - + scope.nextBody = {} + scope.nextHead = [] scope.plugins = loadPlugins(scope); try { - const tree = await generateTree(generator.starter(), scope); - scope.body = render(tree, scope); + if (environment.production || environment.mode !== 'spa') { + const tree = await generateTree(generator.starter(), scope); + scope.body = render(tree, scope); + } if (!online) { context.page.status = 200; } @@ -45,6 +48,8 @@ export async function prerender(request, response) { context.page.status = 500; } finally { if (context.page.status !== 200) { + scope.nextBody = {} + scope.nextHead = [] for (const key in context.router._routes) { delete context.router._routes[key]; } diff --git a/server/project.js b/server/project.js index 1e755c91..83915cbf 100644 --- a/server/project.js +++ b/server/project.js @@ -1,6 +1,6 @@ import environment from './environment'; -import server from './server'; import worker from './worker'; +import reqres from './reqres'; const project = {}; @@ -19,9 +19,18 @@ project.favicon = '/favicon-96x96.png'; project.disallow = []; project.icons = JSON.parse(`{{NULLSTACK_PROJECT_ICONS}}`); +function getHost() { + if (reqres.request?.headers?.host) { + return reqres.request.headers.host + } + if (project.domain === 'localhost') { + return `localhost:${process.env['NULLSTACK_SERVER_PORT']}` + } + return project.domain +} + export function generateBase() { - const port = project.domain === 'localhost' ? `:${server.port}` : ''; - return `${worker.protocol}://${project.domain}${port}`; + return `${worker.protocol}://${getHost()}`; } export default project; \ No newline at end of file diff --git a/server/render.js b/server/render.js index c9e3a994..a67f2d18 100644 --- a/server/render.js +++ b/server/render.js @@ -1,58 +1,75 @@ import { isFalse } from "../shared/nodes"; -import {sanitizeHtml} from "../shared/sanitizeString"; +import { sanitizeHtml } from "../shared/sanitizeString"; +import renderAttributes from "./renderAttributes"; -export default function render(node, scope) { +function isSelfClosing(type) { + if (type === 'input') return true; + if (type === 'img') return true; + if (type === 'link') return true; + if (type === 'meta') return true; + if (type === 'br') return true; + if (type === 'hr') return true; + if (type === 'area') return true; + if (type === 'base') return true; + if (type === 'col') return true; + if (type === 'embed') return true; + if (type === 'param') return true; + if (type === 'source') return true; + if (type === 'track') return true; + if (type === 'wbr') return true; + if (type === 'menuitem') return true; + return false; +} - if(isFalse(node)) { +function renderBody(node, scope, next) { + if (isFalse(node)) { return ""; } - - if(node.type === undefined) { - return (sanitizeHtml(node.toString()) || ' ') + ""; + if (node.type === 'text') { + const text = node.text === '' ? ' ' : sanitizeHtml(node.text.toString()) + return next && next.type === 'text' ? text + "" : text } - let element = `<${node.type}`; - - for(let name in node.attributes) { - if(!name.startsWith('on') && name !== 'html') { - const type = typeof(node.attributes[name]); - if(type !== 'object' && type !== 'function') { - if(name != 'value' && node.attributes[name] === true) { - element += ` ${name}`; - } else if(name == 'value' || (node.attributes[name] !== false && node.attributes[name] !== null && node.attributes[name] !== undefined)) { - element += ` ${name}="${node.attributes[name]}"`; - } - } - } - } - - const selfClosing = ['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr', 'menuitem'].includes(node.type); - if(selfClosing && node.children.length === 0) { + element += renderAttributes(node.attributes) + if (isSelfClosing(node.type)) { element += '/>'; - } else { element += '>'; - if(node.attributes.html) { + if (node.attributes.html) { const source = node.attributes.html; - if(node.type === 'head') { - scope.head += source; - } else { - element += source; - } - } else if(node.type === 'textarea') { - element += node.children[0]; + element += source; + } else if (node.type === 'textarea') { + element += node.children[0].text; } else { - for(let i = 0; i < node.children.length; i++) { - const source = render(node.children[i], scope); - if(node.type === 'head') { - scope.head += source; - } else { - element += source; - } + for (let i = 0; i < node.children.length; i++) { + element += renderBody(node.children[i], scope, node.children[i + 1]); } } element += ``; } - - return node.type === 'head' ? '' : element; + return element; +} + +function renderHead(scope) { + const limit = scope.nextHead.length + for (let i = 0; i < limit; i++) { + const node = scope.nextHead[i] + if (isFalse(node)) { + continue; + } + scope.head += `<${node.type}`; + scope.head += renderAttributes(node.attributes) + if (isSelfClosing(node.type)) { + scope.head += '/>'; + } else { + scope.head += '>'; + scope.head += node.attributes.html + scope.head += `` + } + } +} + +export default function render(node, scope, next) { + renderHead(scope) + return renderBody(node, scope, next) } \ No newline at end of file diff --git a/server/renderAttributes.js b/server/renderAttributes.js new file mode 100644 index 00000000..01e5a37a --- /dev/null +++ b/server/renderAttributes.js @@ -0,0 +1,25 @@ +import generateTruthyString from "../shared/generateTruthyString"; + +export default function renderAttributes(attributes) { + let element = '' + for (let name in attributes) { + if (name === 'debounce') continue + if (!name.startsWith('on') && name !== 'html') { + let attribute = attributes[name]; + if ((name === 'class' || name === 'style') && Array.isArray(attributes[name])) { + attribute = generateTruthyString(attributes[name]); + } else { + attribute = attributes[name]; + } + const type = typeof attribute; + if (type !== 'object' && type !== 'function') { + if (name != 'value' && attribute === true) { + element += ` ${name}`; + } else if (name === 'value' || (attribute !== false && attribute !== null && attribute !== undefined)) { + element += ` ${name}="${attribute}"`; + } + } + } + } + return element +} \ No newline at end of file diff --git a/server/reqres.js b/server/reqres.js new file mode 100644 index 00000000..1e86fc66 --- /dev/null +++ b/server/reqres.js @@ -0,0 +1,2 @@ +const reqres = {} +export default reqres \ No newline at end of file diff --git a/server/server.js b/server/server.js index 7bb7542e..919b1f83 100644 --- a/server/server.js +++ b/server/server.js @@ -1,7 +1,6 @@ import bodyParser from 'body-parser'; import cors from 'cors'; import express from 'express'; -import http from 'http'; import fetch from 'node-fetch'; import path from 'path'; import deserialize from '../shared/deserialize'; @@ -9,29 +8,29 @@ import prefix from '../shared/prefix'; import context, { generateContext } from './context'; import environment from './environment'; import { generateFile } from './files'; -import liveReload from './liveReload'; import generateManifest from './manifest'; import { prerender } from './prerender'; import printError from './printError'; -import project from './project'; import registry from './registry'; import generateRobots from './robots'; import template from './template'; import { generateServiceWorker } from './worker'; +import reqres from './reqres' +import WebSocket from 'ws'; +import { writeFileSync } from 'fs' if (!global.fetch) { global.fetch = fetch; } -const app = express(); -const server = http.createServer(app); +const server = express(); -server.port ??= process.env['NULLSTACK_SERVER_PORT'] || process.env['PORT']; +server.port = process.env['NULSTACK_SERVER_PORT_YOU_SHOULD_NOT_CARE_ABOUT'] || process.env['NULLSTACK_SERVER_PORT'] || process.env['PORT'] || 3000 let contextStarted = false let serverStarted = false -app.use(async (request, response, next) => { +server.use(async (request, response, next) => { if (!contextStarted) { typeof context.start === 'function' && await context.start(); contextStarted = true; @@ -39,12 +38,6 @@ app.use(async (request, response, next) => { next() }) -for (const methodName of ['use', 'delete', 'get', 'head', 'options', 'patch', 'post', 'put']) { - server[methodName] = function () { - app[methodName](...arguments); - } -} - function createRequest(path) { return { method: "GET", @@ -88,7 +81,7 @@ function createResponse(callback) { }; res.getHeader = (x) => headers[x]; res.redirect = function (_code, url) { - if (typeof (_code) !== 'number') { + if (typeof _code !== 'number') { code = 301; url = _code; } else { @@ -101,7 +94,7 @@ function createResponse(callback) { code = number; return res; }; - res.end = res.send = res.write = function (data) { + res.end = res.send = res.write = function (data = '') { if (callback) callback(code, data, headers); }; return res; @@ -110,7 +103,7 @@ function createResponse(callback) { server.prerender = async function (originalUrl, options) { server.start() return new Promise((resolve, reject) => { - app._router.handle( + server._router.handle( createRequest(originalUrl), createResponse((code, data, headers) => resolve(data)), () => { } @@ -123,53 +116,57 @@ server.start = function () { if (serverStarted) return; serverStarted = true; - app.use(cors(server.cors)); + server.use(cors(server.cors)); - app.use(express.static(path.join(__dirname, '..', 'public'))); + server.use(express.static(path.join(__dirname, '..', 'public'))); - app.use(bodyParser.text({ limit: server.maximumPayloadSize })); + server.use(bodyParser.text({ limit: server.maximumPayloadSize })); - app.get(`/:number.client.js`, (request, response) => { - response.setHeader('Cache-Control', 'max-age=31536000, immutable'); - response.contentType('text/javascript'); - response.send(generateFile(`${request.params.number}.client.js`, server)); - }); + if (environment.production) { - app.get(`/:number.client.css`, (request, response) => { - response.setHeader('Cache-Control', 'max-age=31536000, immutable'); - response.contentType('text/css'); - response.send(generateFile(`${request.params.number}.client.css`, server)); - }); + server.get(`/:number.client.js`, (request, response) => { + response.setHeader('Cache-Control', 'max-age=31536000, immutable'); + response.contentType('text/javascript'); + response.send(generateFile(`${request.params.number}.client.js`, server)); + }); - app.get(`/client.css`, (request, response) => { - response.setHeader('Cache-Control', 'max-age=31536000, immutable'); - response.contentType('text/css'); - response.send(generateFile('client.css', server)); - }); + server.get(`/:number.client.css`, (request, response) => { + response.setHeader('Cache-Control', 'max-age=31536000, immutable'); + response.contentType('text/css'); + response.send(generateFile(`${request.params.number}.client.css`, server)); + }); - app.get(`/client.js`, (request, response) => { - response.setHeader('Cache-Control', 'max-age=31536000, immutable'); - response.contentType('text/javascript'); - response.send(generateFile('client.js', server)); - }); + server.get(`/client.css`, (request, response) => { + response.setHeader('Cache-Control', 'max-age=31536000, immutable'); + response.contentType('text/css'); + response.send(generateFile('client.css', server)); + }); - app.get(`/manifest.webmanifest`, (request, response) => { + server.get(`/client.js`, (request, response) => { + response.setHeader('Cache-Control', 'max-age=31536000, immutable'); + response.contentType('text/javascript'); + response.send(generateFile('client.js', server)); + }); + + } + + server.get(`/manifest.webmanifest`, (request, response) => { response.setHeader('Cache-Control', 'max-age=31536000, immutable'); response.contentType('application/manifest+json'); response.send(generateManifest(server)); }); - app.get(`/service-worker.js`, (request, response) => { + server.get(`/service-worker.js`, (request, response) => { response.setHeader('Cache-Control', 'max-age=31536000, immutable'); response.contentType('text/javascript'); response.send(generateServiceWorker()); }); - app.get('/robots.txt', (request, response) => { + server.get('/robots.txt', (request, response) => { response.send(generateRobots()); }); - app.all(`/${prefix}/:hash/:methodName.json`, async (request, response) => { + server.all(`/${prefix}/:hash/:methodName.json`, async (request, response) => { const payload = request.method === 'GET' ? request.query.payload : request.body; const args = deserialize(payload); const { hash, methodName } = request.params; @@ -198,15 +195,22 @@ server.start = function () { } }); - app.get('*', async (request, response, next) => { + server.get('*', async (request, response, next) => { if (request.originalUrl.split('?')[0].indexOf('.') > -1) { return next(); } + reqres.request = request + reqres.response = response const scope = await prerender(request, response); if (!response.headersSent) { const status = scope.context.page.status; const html = template(scope); + reqres.request = null + reqres.response = null response.status(status).send(html); + } else { + reqres.request = null + reqres.response = null } }); @@ -216,17 +220,23 @@ server.start = function () { process.exit(); } - server.listen(server.port, () => { - const name = project.name ? project.name : 'Nullstack' + server.listen(server.port, async () => { if (environment.development) { - liveReload(server); - console.log('\x1b[36m%s\x1b[0m', ` βœ…οΈ ${name} is ready at http://localhost:${server.port}\n`); + if (process.env['NULLSTACK_ENVIRONMENT_DISK'] === 'true') { + const content = await server.prerender('/'); + const target = process.cwd() + `/.development/index.html` + writeFileSync(target, content) + } + const socket = new WebSocket(`ws://localhost:${process.env['NULLSTACK_SERVER_PORT']}/ws`); + socket.onopen = async function (e) { + socket.send('{"type":"NULLSTACK_SERVER_STARTED"}') + } } else { - console.log('\x1b[36m%s\x1b[0m', ` βœ…οΈ ${name} is ready at http://127.0.0.1:${server.port}\n`); + console.log('\x1b[36m%s\x1b[0m', ` βœ…οΈ Your application is ready at http://${process.env['NULLSTACK_PROJECT_DOMAIN']}:${process.env['NULLSTACK_SERVER_PORT']}\n`); } }); } } -export default server; \ No newline at end of file +export default server; diff --git a/server/template.js b/server/template.js index e2d1f7df..e4c2879c 100644 --- a/server/template.js +++ b/server/template.js @@ -4,16 +4,16 @@ import integrities from './integrities'; import { absolute, cdn, cdnOrAbsolute } from './links'; import project from './project'; import settings from './settings'; +import renderAttributes from './renderAttributes'; -export default function ({ head, body, context, instances }) { - const timestamp = environment.development ? `×tamp=${+new Date()}` : '' +export default function ({ head, body, nextBody, context, instances }) { const { page, router, worker, params } = context; const canonical = absolute(page.canonical || router.url); const image = cdnOrAbsolute(page.image); const serializableContext = {}; const blacklist = ['scope', 'router', 'page', 'environment', 'settings', 'worker', 'params', 'project', 'instances']; for (const [key, value] of Object.entries(context)) { - if (!blacklist.includes(key) && typeof (value) !== 'function') { + if (!blacklist.includes(key) && typeof value !== 'function') { serializableContext[key] = value; } } @@ -53,15 +53,15 @@ export default function ({ head, body, context, instances }) { ${page.robots ? `` : ''} ${project.viewport ? `` : ''} - + ${page.schema ? `` : ''} ${project.icons['180'] ? `` : ''} ${head.split('').join('')} - + - + ${environment.mode === 'spa' ? '
' : body} `) diff --git a/server/worker.js b/server/worker.js index 3cc0233d..922af9a5 100644 --- a/server/worker.js +++ b/server/worker.js @@ -72,7 +72,7 @@ export function generateServiceWorker() { if (original) { sources.push(original); } - files['service-worker.js'] = sources.join(`\n\n`).replace(`"{{BUNDLE}}"`, scripts.join(', \n'));; + files['service-worker.js'] = sources.join(`\n\n`).replace(`"{{BUNDLE}}",`, scripts.join(', \n')); return files['service-worker.js']; } diff --git a/shared/element.js b/shared/element.js index e53a21ca..75ff247a 100644 --- a/shared/element.js +++ b/shared/element.js @@ -1,25 +1,26 @@ import fragment from './fragment'; -function flattenChildren(children) { - children = [].concat.apply([], children).map((child) => { - if(child === null || child === undefined) return false; - return child; - }); - return [].concat.apply([], children); +const seed = Object.freeze([]) + +function normalize(child) { + return child ?? false } export default function element(type, props, ...children) { - children = flattenChildren(children); - if(type === 'textarea') { + children = seed.concat(...children).map(normalize) + if (type === 'textarea') { children = [children.join('')]; } - const attributes = {...props, children}; - if(type === 'element') { + const attributes = { ...props, children }; + if (type === 'style' && !attributes.html) { + attributes.html = children.join(''); + } + if (type === 'element') { type = attributes.tag || fragment; delete attributes.tag; } - if(typeof(type) === 'function' && type.render !== undefined) { - return {type, attributes, children: null} + if (typeof type === 'function' && type.render !== undefined) { + return { type, attributes, children: null } } - return {type, attributes, children}; + return { type, attributes, children }; } \ No newline at end of file diff --git a/shared/generateKey.js b/shared/generateKey.js index 8fdb0f80..72eaa113 100644 --- a/shared/generateKey.js +++ b/shared/generateKey.js @@ -1,4 +1,8 @@ -export default function generateKey(node, depth) { - if (depth.length === 1) return 'application'; - return node.type.name + '/' + depth.join('-'); +export default function generateKey(scope, node, depth) { + if (node.attributes.key) return node.attributes.key; + const prefix = depth.length === 1 ? 'application' : node.type.name + '/' + depth; + if (node.attributes.route) { + return prefix + (scope.context.environment.mode === 'ssg' ? scope.context.router.path : scope.context.router.url) + } + return prefix; } \ No newline at end of file diff --git a/shared/generateTree.js b/shared/generateTree.js index 0b624e8c..79ba71dc 100644 --- a/shared/generateTree.js +++ b/shared/generateTree.js @@ -1,8 +1,9 @@ import generateKey from '../shared/generateKey'; import { isClass, isFalse, isFunction, isUndefined } from '../shared/nodes'; +import fragment from './fragment'; import { transformNodes } from './plugins'; -async function generateBranch(parent, node, depth, scope) { +async function generateBranch(siblings, node, depth, scope) { transformNodes(scope, node, depth); @@ -14,57 +15,41 @@ async function generateBranch(parent, node, depth, scope) { message += 'This error usually happens because of a missing import statement or a typo on a component tag'; } throw new Error(message) - return; } if (isFalse(node)) { - parent.children.push(false); + siblings.push({ + type: false, + attributes: {} + }); return; } if (isClass(node)) { - const key = node.attributes.key ? node.attributes.key : generateKey(node, depth) + (node.attributes.route ? (scope.context.environment.mode === 'ssg' ? scope.context.router.path : scope.context.router.url) : '') - if ( - scope.context.environment.client && - scope.context.router._changed && - node.attributes && - node.attributes.route && - scope.context.environment.mode !== 'ssg' - ) { - const routeDepth = depth.slice(0, -1).join('.'); - const newSegments = scope.context.router._newSegments[routeDepth]; - if (newSegments) { - const oldSegments = scope.context.router._oldSegments[routeDepth]; - for (const segment in newSegments) { - if (oldSegments[segment] !== newSegments[segment]) { - delete scope.memory[key]; - } - } - } - } + const key = generateKey(scope, node, depth) const instance = scope.instances[key] || new node.type(scope); - instance._self.persistent = !!node.attributes.persistent - instance._self.key = key; + instance.persistent = !!node.attributes.persistent + instance.key = key; instance._attributes = node.attributes; instance._scope = scope; let memory; if (scope.memory) { memory = scope.memory[key]; if (memory) { - instance._self.prerendered = true; - instance._self.initiated = true; + instance.prerendered = true; + instance.initiated = true; Object.assign(instance, memory); delete scope.memory[key]; } } let shouldHydrate = false; - const shouldLaunch = instance._self.initiated && ( - !instance._self.prerendered || - (instance._self.persistent && instance._self.terminated) + const shouldLaunch = instance.initiated && ( + !instance.prerendered || + (instance.persistent && instance.terminated) ) - if (instance._self.terminated) { + if (instance.terminated) { shouldHydrate = true; - instance._self.terminated = false; + instance.terminated = false; } const shouldPrepare = scope.instances[key] === undefined; scope.instances[key] = instance; @@ -73,7 +58,7 @@ async function generateBranch(parent, node, depth, scope) { instance.prepare && instance.prepare(); if (scope.context.environment.server) { instance.initiate && await instance.initiate(); - instance._self.initiated = true; + instance.initiated = true; instance.launch && instance.launch(); } else { scope.initiationQueue.push(instance); @@ -85,7 +70,7 @@ async function generateBranch(parent, node, depth, scope) { if (shouldHydrate) { shouldLaunch && instance.launch && instance.launch(); scope.hydrationQueue.push(instance); - } else if (instance._self.initiated == true) { + } else if (instance.initiated === true) { instance.update && instance.update(); } } @@ -98,43 +83,80 @@ async function generateBranch(parent, node, depth, scope) { } node.children = [].concat(children); for (let i = 0; i < node.children.length; i++) { - await generateBranch(parent, node.children[i], [...depth, i], scope); + await generateBranch(siblings, node.children[i], depth + '-' + i, scope); } return; } + if (node.type === 'body') { + node.type = fragment + for (const attribute in node.attributes) { + if (attribute === 'children' || attribute.startsWith('_')) continue; + if (attribute === 'class' || attribute === 'style') { + if (!scope.nextBody[attribute]) { + scope.nextBody[attribute] = [] + } + scope.nextBody[attribute].push(node.attributes[attribute]) + } else if (attribute.startsWith('on')) { + if (scope.context.environment.server) continue + if (!scope.nextBody[attribute]) { + scope.nextBody[attribute] = [] + } + if (Array.isArray(node.attributes[attribute])) { + scope.nextBody[attribute].push(...node.attributes[attribute]) + } else { + scope.nextBody[attribute].push(node.attributes[attribute]) + } + } else { + scope.nextBody[attribute] = node.attributes[attribute] + } + } + } + if (isFunction(node)) { const context = node.type.name ? scope.generateContext(node.attributes) : node.attributes; const children = node.type(context); node.children = [].concat(children); for (let i = 0; i < node.children.length; i++) { - await generateBranch(parent, node.children[i], [...depth, i], scope); + await generateBranch(siblings, node.children[i], depth + '-' + i, scope); } return; } if (node.type) { - const branch = { - type: node.type, - attributes: node.attributes || {}, - instance: node.instance, - children: [] - } - if (node.children) { + if (node.type === 'head') { + siblings.push({ + type: false, + attributes: {} + }); + for (let i = 0; i < node.children.length; i++) { + const id = depth + '-' + i + await generateBranch(scope.nextHead, node.children[i], id, scope); + scope.nextHead[scope.nextHead.length - 1].attributes.id ??= id + } + } else if (node.children) { + const branch = { + type: node.type, + attributes: node.attributes, + children: [] + } for (let i = 0; i < node.children.length; i++) { - await generateBranch(branch, node.children[i], [...depth, i], scope); + await generateBranch(branch.children, node.children[i], depth + '-' + i, scope); } + siblings.push(branch); } - parent.children.push(branch); return; } - parent.children.push(node); + siblings.push({ + type: 'text', + text: node + }); } export default async function generateTree(node, scope) { const tree = { type: 'div', attributes: { id: 'application' }, children: [] }; - await generateBranch(tree, node, [0], scope); + await generateBranch(tree.children, node, '0', scope); return tree; } \ No newline at end of file diff --git a/shared/generateTruthyString.js b/shared/generateTruthyString.js new file mode 100644 index 00000000..6978dcdf --- /dev/null +++ b/shared/generateTruthyString.js @@ -0,0 +1,5 @@ +const seed = Object.freeze([]) + +export default function generateTruthyString(elements) { + return seed.concat(...elements).filter(Boolean).join(' ') +} \ No newline at end of file diff --git a/shared/nodes.js b/shared/nodes.js index b7490a47..8644758a 100644 --- a/shared/nodes.js +++ b/shared/nodes.js @@ -1,5 +1,6 @@ export function isUndefined(node) { if (node === undefined) return true + if (node === null) return false return node.hasOwnProperty('type') && node.type === undefined } @@ -9,13 +10,13 @@ export function isFalse(node) { } export function isClass(node) { - return typeof (node.type) === 'function' && node.type.prototype && typeof (node.type.prototype.render) === 'function'; + return typeof node.type === 'function' && node.type.prototype && typeof node.type.prototype.render === 'function'; } export function isFunction(node) { - return typeof (node.type) === 'function'; + return typeof node.type === 'function'; } export function isText(node) { - return typeof (node.children) === 'undefined'; + return node.type === 'text' } \ No newline at end of file diff --git a/shared/noop.js b/shared/noop.js new file mode 100644 index 00000000..7a9ff1d5 --- /dev/null +++ b/shared/noop.js @@ -0,0 +1 @@ +export default function noop() { } \ No newline at end of file diff --git a/shared/plugins.js b/shared/plugins.js index 5b9c702e..90c5f0ce 100644 --- a/shared/plugins.js +++ b/shared/plugins.js @@ -1,36 +1,32 @@ import routable from '../plugins/routable'; import bindable from '../plugins/bindable'; -import datable from '../plugins/datable'; import parameterizable from '../plugins/parameterizable'; import anchorable from '../plugins/anchorable'; -import objectable from '../plugins/objectable'; -let plugins = [ - objectable, +const plugins = [ parameterizable, anchorable, routable, - datable, bindable ]; export function transformNodes(scope, node, depth) { - for(const plugin of plugins) { - plugin.transform({...scope.context, node, depth}); + for (const plugin of plugins) { + plugin.transform({ ...scope.context, node, depth }); } } export function loadPlugins(scope) { - for(const plugin of plugins) { + for (const plugin of plugins) { plugin.load && plugin.load(scope.context) } return plugins; } -export function usePlugins(environment) { - return async (...userPlugins) => { - plugins = [ - ...new Set([...userPlugins.flat(), ...plugins]) - ].filter((plugin) => plugin[environment]) - } +export function useClientPlugins(plugin) { + if (plugin.client) plugins.push(plugin) +} + +export function useServerPlugins(plugin) { + if (plugin.server) plugins.push(plugin) } \ No newline at end of file diff --git a/shared/serializeParam.js b/shared/serializeParam.js index ef687b0f..941aa0fc 100644 --- a/shared/serializeParam.js +++ b/shared/serializeParam.js @@ -1,3 +1,3 @@ export default function serializeParam(value) { - return value && value.toJSON !== undefined ? value.toJSON() : value; + return value?.toJSON?.() ?? value; } \ No newline at end of file diff --git a/tests/.env b/tests/.env new file mode 100644 index 00000000..c1b9185f --- /dev/null +++ b/tests/.env @@ -0,0 +1,10 @@ +NULLSTACK_SECRETS_KEY = 'secrets' +NULLSTACK_SECRETS_CAMELIZED_KEY = 'secrets' +NULLSTACK_SETTINGS_KEY = 'settings' +NULLSTACK_SETTINGS_CAMELIZED_KEY = 'settings' +NULLSTACK_PROJECT_NAME = 'Nullstack Tests' +NULLSTACK_PROJECT_SHORT_NAME = 'Nullstack' +NULLSTACK_PROJECT_DOMAIN = 'localhost' +NULLSTACK_WORKER_CDN = 'http://localhost:6969' +NULLSTACK_WORKER_API = 'http://127.0.0.1:6969' +NULLSTACK_SERVER_PORT = '6969' \ No newline at end of file diff --git a/tests/.env.test b/tests/.env.test index fe46256e..76c1656d 100644 --- a/tests/.env.test +++ b/tests/.env.test @@ -5,5 +5,5 @@ NULLSTACK_SETTINGS_CAMELIZED_KEY = 'settings' NULLSTACK_PROJECT_NAME='Nullstack Tests' NULLSTACK_PROJECT_SHORT_NAME='Nullstack' NULLSTACK_PROJECT_DOMAIN='localhost' -NULLSTACK_WORKER_CDN='http://127.0.0.1:6969' -NULLSTACK_WORKER_API='http://127.0.0.1:6969' \ No newline at end of file +NULLSTACK_WORKER_CDN='http://localhost:6969' +NULLSTACK_WORKER_API='http://127.0.0.1:6970' \ No newline at end of file diff --git a/tests/client.js b/tests/client.js index 7d1295ed..8268343f 100644 --- a/tests/client.js +++ b/tests/client.js @@ -2,7 +2,7 @@ import Nullstack from 'nullstack'; import Application from './src/Application'; import vueable from './src/plugins/vueable'; -Nullstack.use([vueable], vueable); +Nullstack.use(vueable); const context = Nullstack.start(Application); diff --git a/tests/jest-puppeteer.config.js b/tests/jest-puppeteer.config.js index e9a1eb35..d24779c4 100644 --- a/tests/jest-puppeteer.config.js +++ b/tests/jest-puppeteer.config.js @@ -1,9 +1,9 @@ -// get environment variable -const CI = !!process.env.CI; - -const baseOptions = { +const defaultOptions = { + launch: { + headless: true, + }, server: { - command: 'npm run start', + command: 'npm run build && node .production/server.js', port: 6969, launchTimeout: 25000 }, @@ -22,7 +22,12 @@ const ciPipelineOptions = { '--disable-gpu' ] }, - ...baseOptions + server: { + command: 'npm run build && node .production/server.js', + port: 6969, + launchTimeout: 25000 + }, + browserContext: 'incognito' } -module.exports = CI ? ciPipelineOptions : baseOptions; \ No newline at end of file +module.exports = process.env.CI ? ciPipelineOptions : defaultOptions; \ No newline at end of file diff --git a/tests/jest.config.js b/tests/jest.config.js index f41e41d6..b6c36829 100644 --- a/tests/jest.config.js +++ b/tests/jest.config.js @@ -3,5 +3,5 @@ const CI = !!process.env.CI; module.exports = { preset: "jest-puppeteer", forceExit: CI, - testTimeout: CI ? 5000 : 20000 + testTimeout: 60000 } \ No newline at end of file diff --git a/tests/package.json b/tests/package.json index 25239476..c73a7a44 100644 --- a/tests/package.json +++ b/tests/package.json @@ -5,17 +5,17 @@ "author": "", "license": "ISC", "devDependencies": { - "jest": "^26.6.3", + "glob": "^8.0.3", + "jest": "^28.1.0", + "jest-puppeteer": "^6.1.0", "nullstack": "*", - "puppeteer": "^5.5.0", - "jest-puppeteer": "^6.0.3", - "glob": "^7.1.7", + "puppeteer": "^14.1.1", "purgecss-webpack-plugin": "^4.1.3" }, "scripts": { - "start": "npx nullstack start --input=./tests --port=6969 --env=test", - "build": "npx nullstack build --input=./tests --mode=ssr --env=test", - "test": "npm run build && jest", + "start": "npx nullstack start --input=./tests --port=6969 --env=test --mode=spa", + "build": "npx nullstack build --input=./tests --env=test", + "test": "npm run build && jest --runInBand", "script": "node src/scripts/run.js" } } \ No newline at end of file diff --git a/tests/server.js b/tests/server.js index 1ae9b42a..b8c05793 100644 --- a/tests/server.js +++ b/tests/server.js @@ -7,12 +7,16 @@ import ContextWorker from './src/ContextWorker'; import vueable from './src/plugins/vueable'; import ServerRequestAndResponse from './src/ServerRequestAndResponse'; -Nullstack.use([vueable], vueable); +Nullstack.use(vueable); const context = Nullstack.start(Application); const methods = ['DELETE', 'GET', 'HEAD', 'OPTIONS', 'PATCH', 'POST', 'PUT']; +context.server.get('/custom-api-before-start', (request, response) => { + response.json({ startValue: context.startValue }) +}) + context.server.use('/api', (request, response, next) => { request.status = 200; if (!response.headersSent) { @@ -22,7 +26,7 @@ context.server.use('/api', (request, response, next) => { for (const method of methods) { context.server[method.toLowerCase()]('/api', (request, response) => { - response.status(request.status).send(request.method); + response.status(request.status).json({ method: request.method }); }); } diff --git a/tests/src/AnchorModifiers.njs b/tests/src/AnchorModifiers.njs index 1306e73f..449509ae 100644 --- a/tests/src/AnchorModifiers.njs +++ b/tests/src/AnchorModifiers.njs @@ -6,23 +6,45 @@ class AnchorModifiers extends Nullstack { html ` - hydrate(context) { - context.self.element.querySelector('a').addEventListener('click', () => { - context.clickedHTML = true + count = 0 + objected = false + updated = false + + hydrate() { + this._element.querySelector('a').addEventListener('click', () => { + this.clickedHTML = true }) } - clickJSX(context) { - context.clickedJSX = true + clickJSX() { + this.clickedJSX = true + } + + markAsUpdated() { + document.body.dataset.updated = true + } + + increment() { + this.count++ } - render({ clickedJSX, clickedHTML }) { + render() { return ( -
+
) } diff --git a/tests/src/AnchorModifiers.test.js b/tests/src/AnchorModifiers.test.js index 1e071298..da255ee4 100644 --- a/tests/src/AnchorModifiers.test.js +++ b/tests/src/AnchorModifiers.test.js @@ -1,9 +1,10 @@ -beforeAll(async () => { - await page.goto('http://localhost:6969/anchor-modifiers'); -}); - describe('AnchorModifiers jsx', () => { + beforeEach(async () => { + await page.goto('http://localhost:6969/anchor-modifiers'); + await page.waitForSelector('[data-hydrated]'); + }); + test('Clicking html link with shift opens in new window', async () => { await page.keyboard.down('Shift'); await page.click('[href="/anchor-modifiers?source=html"]'); @@ -72,4 +73,28 @@ describe('AnchorModifiers jsx', () => { expect(element).toBeTruthy(); }); + test('anchors can have events', async () => { + await page.click('button'); + await page.click('[href="/anchor-modifiers?source=incremented"]'); + await page.waitForSelector('[data-updated] [data-count="1"]'); + const element = await page.$('[data-updated] [data-count="1"]'); + expect(element).toBeTruthy(); + }); + + test('anchors can have object events', async () => { + await page.click('button'); + await page.click('[href="/anchor-modifiers?source=object"]'); + await page.waitForSelector('[data-updated] [data-objected]'); + const element = await page.$('[data-updated] [data-objected]'); + expect(element).toBeTruthy(); + }); + + test('anchors can have array events', async () => { + await page.click('button'); + await page.click('[href="/anchor-modifiers?source=array"]'); + await page.waitForSelector('[data-updated] [data-count="1"][data-objected]'); + const element = await page.$('[data-updated] [data-count="1"][data-objected]'); + expect(element).toBeTruthy(); + }); + }); diff --git a/tests/src/Application.njs b/tests/src/Application.njs index a1426389..af60504b 100644 --- a/tests/src/Application.njs +++ b/tests/src/Application.njs @@ -46,7 +46,13 @@ import WorkerVerbs from './WorkerVerbs'; import MetatagState from './MetatagState'; import TypeScriptExtension from './TypeScriptExtension'; import JavaScriptExtension from './JavaScriptExtension'; -import HydrateElement from './HydrateElement'; +import Refs from './Refs'; +import OptimizedEvents from './OptimizedEvents'; +import DynamicHead from './DynamicHead' +import TextObserver from './TextObserver'; +import BodyFragment from './BodyFragment'; +import ArrayAttributes from './ArrayAttributes'; +import RouteScroll from './RouteScroll'; class Application extends Nullstack { @@ -56,11 +62,12 @@ class Application extends Nullstack { prepare(context) { context.string = 'nullstack'; + context.refInstanceCount = 0 } - render({ project, page, environment }) { + render({ project, page, environment, refInstanceCount }) { return ( -
+

{project.name}

{page.status !== 200 &&
} @@ -112,13 +122,19 @@ class Application extends Nullstack { - + - + + + + + + + -
+ ) } diff --git a/tests/src/Application.test.js b/tests/src/Application.test.js index 3b7c1a6d..a21d4db5 100644 --- a/tests/src/Application.test.js +++ b/tests/src/Application.test.js @@ -8,7 +8,7 @@ describe('Application', () => { test('the static start function runs on startup', async () => { const h1 = await page.$('h1'); - const text = await page.evaluate(element => element.innerText, h1); + const text = await page.evaluate(element => element.textContent, h1); expect(text).toMatch('Nullstack Tests'); }); diff --git a/tests/src/ArrayAttributes.njs b/tests/src/ArrayAttributes.njs new file mode 100644 index 00000000..104f24d9 --- /dev/null +++ b/tests/src/ArrayAttributes.njs @@ -0,0 +1,39 @@ +import Nullstack from 'nullstack'; + +class ArrayAttributes extends Nullstack { + + classes = 'c' + styles = 'color: black;' + count = 1 + objected = false + + increment() { + this.count++ + } + + double() { + this.count += this.count + } + + render() { + return ( +
+ {JSON.stringify(this.classes)} + {JSON.stringify(this.styles)} + + + + + + + + + + {this.count > 1 && } +
+ ) + } + +} + +export default ArrayAttributes; \ No newline at end of file diff --git a/tests/src/ArrayAttributes.test.js b/tests/src/ArrayAttributes.test.js new file mode 100644 index 00000000..3067423d --- /dev/null +++ b/tests/src/ArrayAttributes.test.js @@ -0,0 +1,96 @@ +describe('ArrayAttributes jsx', () => { + + beforeEach(async () => { + await page.goto('http://localhost:6969/array-attributes'); + await page.waitForSelector('[data-hydrated]') + }); + + test('classes can be simple strings', async () => { + await page.click('[data-d]'); + await page.waitForSelector('[class="d"]') + const element = await page.$('[class="d"]') + expect(element).toBeTruthy(); + }); + + test('when class refers to an array the result is the merge of the strings', async () => { + await page.click('[data-ab]'); + await page.waitForSelector('[class="a b"]') + const element = await page.$('[class="a b"]') + expect(element).toBeTruthy(); + }); + + test('when the class array changes the attribute changes', async () => { + await page.click('[data-ab]'); + await page.waitForSelector('[class="a b"]') + await page.click('[data-abc]'); + await page.waitForSelector('[class="a b c"]') + const element = await page.$('[class="a b c"]') + expect(element).toBeTruthy(); + }); + + test('falsy values are removed from the class array', async () => { + await page.click('[data-e]'); + await page.waitForSelector('[class="e"]') + const element = await page.$('[class="e"]') + expect(element).toBeTruthy(); + }); + + test('styles can be simple strings', async () => { + await page.click('[data-purple]'); + await page.waitForSelector('[style="color: purple;"]') + const element = await page.$('[style="color: purple;"]') + expect(element).toBeTruthy(); + }); + + test('when style refers to an array the result is the merge of the strings', async () => { + await page.click('[data-pink-blue]'); + await page.waitForSelector('[style="color: pink; background-color: blue;"]') + const element = await page.$('[style="color: pink; background-color: blue;"]') + expect(element).toBeTruthy(); + }); + + test('when the style array changes the attribute changes', async () => { + await page.click('[data-pink-blue]'); + await page.waitForSelector('[style="color: pink; background-color: blue;"]') + await page.click('[data-pink-blue-red]'); + await page.waitForSelector('[style="color: pink; background-color: blue; border: 1px solid red;"]') + const element = await page.$('[style="color: pink; background-color: blue; border: 1px solid red;"]') + expect(element).toBeTruthy(); + }); + + test('falsy values are removed from the style array', async () => { + await page.click('[data-green]'); + await page.waitForSelector('[style="color: green;"]') + const element = await page.$('[style="color: green;"]') + expect(element).toBeTruthy(); + }); + + test('when events point to an array all the functions are executed in parallel', async () => { + await page.click('[data-events]'); + await page.waitForSelector('[data-count="4"]') + const element = await page.$('[data-count="4"]') + expect(element).toBeTruthy(); + }); + + test('object events can be mixed with function events in arrays', async () => { + await page.click('[data-events]'); + await page.waitForSelector('[data-count="4"][data-objected]') + const element = await page.$('[data-count="4"][data-objected]') + expect(element).toBeTruthy(); + }); + + test('elements rendered after hydration can receive array classes', async () => { + await page.click('[data-events]'); + await page.waitForSelector('[class="dynamic-a dynamic-b"]') + const element = await page.$('[class="dynamic-a dynamic-b"]') + expect(element).toBeTruthy(); + }); + + test('elements rendered after hydration can receive array styles', async () => { + await page.click('[data-events]'); + await page.waitForSelector('[class="dynamic-a dynamic-b"][style="color: pink; background-color: blue;"]') + const element = await page.$('[class="dynamic-a dynamic-b"][style="color: pink; background-color: blue;"]') + expect(element).toBeTruthy(); + }); + +}); diff --git a/tests/src/BodyFragment.njs b/tests/src/BodyFragment.njs new file mode 100644 index 00000000..c16b2c79 --- /dev/null +++ b/tests/src/BodyFragment.njs @@ -0,0 +1,39 @@ +import Nullstack from 'nullstack'; + +class BodyFragment extends Nullstack { + + count = 0 + visible = false + objected = false + + increment() { + this.count++ + } + + reveal() { + this.visible = !this.visible + } + + countDataKeys({ data }) { + this.hasDataKeys = Object.keys(data).length > 0 + } + + render() { + return ( + + + BodyFragment + + {this.visible && + + BodyFragment2 + + } + home + + ) + } + +} + +export default BodyFragment; \ No newline at end of file diff --git a/tests/src/BodyFragment.test.js b/tests/src/BodyFragment.test.js new file mode 100644 index 00000000..67e302ca --- /dev/null +++ b/tests/src/BodyFragment.test.js @@ -0,0 +1,62 @@ +beforeEach(async () => { + await page.goto('http://localhost:6969/body-fragment'); +}); + +describe('BodyFragment', () => { + + test('the body behaves as a fragment and creates no markup', async () => { + const element = await page.$('body > #application > h1'); + expect(element).toBeTruthy(); + }); + + test('when the body is nested regular attributes are overwritten by the last one in the tree', async () => { + const element = await page.$('body[data-chars="b"]'); + expect(element).toBeTruthy(); + }); + + test('when the body is nested classes are merged togheter', async () => { + const element = await page.$('body[class="class-one class-two class-three class-four"]'); + expect(element).toBeTruthy(); + }); + + test('when the body is nested styles are merged togheter', async () => { + const element = await page.$('body[style="background-color: black; color: white;"]'); + expect(element).toBeTruthy(); + }); + + test('when the body is nested events are invoked sequentially', async () => { + await page.waitForSelector('body[data-hydrated]') + await page.click('body'); + await page.waitForSelector('[data-keys][data-objected][data-visible]'); + const element = await page.$('[data-keys][data-objected][data-visible]'); + expect(element).toBeTruthy(); + }); + + test('when a body is added to the vdom attributes are added', async () => { + await page.waitForSelector('body[data-hydrated]') + await page.click('body'); + await page.waitForSelector('body[data-visible]'); + const element = await page.$('body[data-visible]'); + expect(element).toBeTruthy(); + }); + + test('when a body is removed from the vdom attributes are removed', async () => { + await page.waitForSelector('body[data-hydrated]') + await page.click('body'); + await page.waitForSelector('body[data-visible]'); + await page.click('body'); + await page.waitForSelector('body:not([data-visible])'); + const element = await page.$('body:not([data-visible])'); + expect(element).toBeTruthy(); + }); + + test('the body removes events when the fragment leaves the tree', async () => { + await page.waitForSelector('body[data-hydrated]') + await page.click('[href="/"]'); + await page.waitForSelector('[data-window="shim"]:not([data-count])'); + await page.click('body'); + const element = await page.$('[data-window="shim"]:not([data-count])'); + expect(element).toBeTruthy(); + }); + +}); \ No newline at end of file diff --git a/tests/src/ChildComponent.njs b/tests/src/ChildComponent.njs index ef9d617f..8c212b39 100644 --- a/tests/src/ChildComponent.njs +++ b/tests/src/ChildComponent.njs @@ -9,12 +9,15 @@ class ChildComponent extends ParentComponent { async initiate() { this.parentThis = await this.getParentThis(); this.childThis = await this.getChildThis(); + this.staticChildThis = await ChildComponent.getChildThis(); + this.staticParentThis = await ParentComponent.getParentThis(); } async hydrate() { this.hydratedParentThis = await this.getParentThis(); this.hydratedChildThis = await this.getChildThis(); - console.log(this.constructor.name, this.hydratedParentThis, this.hydratedParentThis == this.constructor.name) + this.staticHydratedChildThis = await ChildComponent.getChildThis(); + this.staticHydratedParentThis = await ParentComponent.getParentThis(); this.bunda = 'true' } @@ -26,6 +29,10 @@ class ChildComponent extends ParentComponent {
+
+
+
+
{this.constructor.name} {this.hydratedParentThis} {String(this.hydratedParentThis === this.constructor.name)}
) diff --git a/tests/src/ChildComponent.test.js b/tests/src/ChildComponent.test.js index c9096c42..a54e8c9d 100644 --- a/tests/src/ChildComponent.test.js +++ b/tests/src/ChildComponent.test.js @@ -19,7 +19,7 @@ describe('ChildComponent', () => { expect(element).toBeTruthy(); }); - test('inherited server functions are bound to the class ssr' , async () => { + test('inherited server functions are bound to the class ssr', async () => { const element = await page.$('[data-parent-this]'); expect(element).toBeTruthy(); }); @@ -30,10 +30,34 @@ describe('ChildComponent', () => { expect(element).toBeTruthy(); }); - test('inherited server functions are bound to the class spa' , async () => { + test('inherited server functions are bound to the class spa', async () => { await page.waitForSelector('[data-hydrated-parent-this]'); const element = await page.$('[data-hydrated-parent-this]'); expect(element).toBeTruthy(); }); + test('static inherited server functions are bound to the original class spa', async () => { + await page.waitForSelector('[data-static-hydrated-parent-this]'); + const element = await page.$('[data-static-hydrated-parent-this]'); + expect(element).toBeTruthy(); + }); + + test('static inherited server functions are bound to the original class ssr', async () => { + await page.waitForSelector('[data-static-parent-this]'); + const element = await page.$('[data-static-parent-this]'); + expect(element).toBeTruthy(); + }); + + test('static server functions are bound to the class in spa', async () => { + await page.waitForSelector('[data-static-hydrated-child-this]'); + const element = await page.$('[data-static-hydrated-child-this]'); + expect(element).toBeTruthy(); + }); + + test('static server functions are bound to the class in srs', async () => { + await page.waitForSelector('[data-static-child-this]'); + const element = await page.$('[data-static-child-this]'); + expect(element).toBeTruthy(); + }); + }); \ No newline at end of file diff --git a/tests/src/Context.njs b/tests/src/Context.njs index f22e0964..ca5947a7 100644 --- a/tests/src/Context.njs +++ b/tests/src/Context.njs @@ -8,26 +8,57 @@ class Context extends Nullstack { context.framework = 'Nullstack'; } - static async getContextKey({framework}) { + static async getContextKey({ framework }) { return framework; } + static staticFunction(context) { + return context === undefined + } + + static _staticUnderlineFunction(context) { + return context === undefined + } + + static async _staticAsyncUnderlineFunction(context) { + return context === undefined + } + + static async invokeStaticAsyncUnderlineFunction() { + return await this._staticAsyncUnderlineFunction() + } + async initiate(context) { await this.setContextKey(); context.framework = await this.getContextKey(); this.setFrameworkInitial(); + this.staticFunctionHasNoContext = await Context.staticFunction() + this.staticUnderlineFunctionHasNoContext = await Context._staticUnderlineFunction() + this.staticAsyncUnderlineFunctionHasNoContext = await Context.invokeStaticAsyncUnderlineFunction() } - setFrameworkInitial({framework}) { + async hydrate() { + this.hydratedStaticFunctionHasNoContext = await Context.staticFunction() + this.hydratedStaticUnderlineFunctionHasNoContext = await Context._staticUnderlineFunction() + this.hydratedStaticAsyncUnderlineFunctionHasNoContext = await Context.invokeStaticAsyncUnderlineFunction() + } + + setFrameworkInitial({ framework }) { this.frameworkInitial = framework[0]; } - - render({framework}) { + + render({ framework }) { return ( -
-
-
-
+
) } diff --git a/tests/src/Context.test.js b/tests/src/Context.test.js index 7d015e5e..e7dc3000 100644 --- a/tests/src/Context.test.js +++ b/tests/src/Context.test.js @@ -29,4 +29,40 @@ describe('Context', () => { expect(element).toBeTruthy(); }); + test('hydrated static async underline function has no context', async () => { + await page.waitForSelector('[data-hydrated-static-async-underline-function-has-no-context]'); + const element = await page.$('[data-hydrated-static-async-underline-function-has-no-context]'); + expect(element).toBeTruthy(); + }); + + test('hydrated static underline function has no context', async () => { + await page.waitForSelector('[data-hydrated-static-underline-function-has-no-context]'); + const element = await page.$('[data-hydrated-static-underline-function-has-no-context]'); + expect(element).toBeTruthy(); + }); + + test('hydrated static function has no context', async () => { + await page.waitForSelector('[data-hydrated-static-function-has-no-context]'); + const element = await page.$('[data-hydrated-static-function-has-no-context]'); + expect(element).toBeTruthy(); + }); + + test('static async underline function has no context', async () => { + await page.waitForSelector('[data-static-async-underline-function-has-no-context]'); + const element = await page.$('[data-static-async-underline-function-has-no-context]'); + expect(element).toBeTruthy(); + }); + + test('static underline function has no context', async () => { + await page.waitForSelector('[data-static-underline-function-has-no-context]'); + const element = await page.$('[data-static-underline-function-has-no-context]'); + expect(element).toBeTruthy(); + }); + + test('static function has no context', async () => { + await page.waitForSelector('[data-static-function-has-no-context]'); + const element = await page.$('[data-static-function-has-no-context]'); + expect(element).toBeTruthy(); + }); + }); \ No newline at end of file diff --git a/tests/src/ContextData.njs b/tests/src/ContextData.njs index 57e9d55e..5c1b6494 100644 --- a/tests/src/ContextData.njs +++ b/tests/src/ContextData.njs @@ -2,29 +2,23 @@ import Nullstack from 'nullstack'; class ContextData extends Nullstack { - count = 1; - - calculate({data}) { - this.count = (this.count * data.multiply) + data.sum; + calculateWithDefault({ data }) { + this.countWithDefault = data.multiplyBy * data.count; } - renderInner({data}) { - return ( -
- {data.frameworkName} -
- ) + calculateWithoutDefault({ data }) { + this.countWithoutDefault = data.setTo; } - - render({data}) { + + render() { return ( -
-
- + -
-
) } diff --git a/tests/src/ContextData.test.js b/tests/src/ContextData.test.js index cc864e6a..ff9014b5 100644 --- a/tests/src/ContextData.test.js +++ b/tests/src/ContextData.test.js @@ -4,20 +4,29 @@ beforeAll(async () => { describe('ContextData', () => { - test('is part of the client context', async () => { - const element = await page.$('[data-data]'); + test('data attributes are added to the dom', async () => { + const element = await page.$('[data-multiply-by="3"]'); expect(element).toBeTruthy(); }); - test('data attributs can be mixed with data-key', async () => { - await page.click('button'); - await page.waitForSelector('[data-count="5"]'); - const element = await page.$('[data-count="5"]'); + test('data attributes are merged into the data attribute and passed to events', async () => { + await page.click('[data-multiply-by="3"]'); + await page.waitForSelector('[data-count-with-default="6"]'); + const element = await page.$('[data-count-with-default="6"]'); expect(element).toBeTruthy(); }); - test('data attribuets are camelized into data key', async () => { - const element = await page.$('[data-framework-name="Nullstack"]'); + test('data attributes are added into a new data object and passed to events if no data attribute is provided', async () => { + await page.click('[data-set-to="2"]'); + await page.waitForSelector('[data-count-without-default="2"]'); + const element = await page.$('[data-count-without-default="2"]'); + expect(element).toBeTruthy(); + }); + + test('data attributes are kebabized when generating the data argument', async () => { + await page.click('[data-set-to="2"]'); + await page.waitForSelector('[data-count-without-default="2"]'); + const element = await page.$('[data-count-without-default="2"]'); expect(element).toBeTruthy(); }); diff --git a/tests/src/ContextEnvironment.test.js b/tests/src/ContextEnvironment.test.js index 2a669b76..c0417a43 100644 --- a/tests/src/ContextEnvironment.test.js +++ b/tests/src/ContextEnvironment.test.js @@ -22,12 +22,12 @@ describe('ContextEnvironment', () => { }); test('has a development key', async () => { - const element = await page.$('[data-development="true"]'); + const element = await page.$('[data-development="false"]'); expect(element).toBeTruthy(); }); test('has a production key', async () => { - const element = await page.$('[data-production="false"]'); + const element = await page.$('[data-production="true"]'); expect(element).toBeTruthy(); }); @@ -42,7 +42,7 @@ describe('ContextEnvironment', () => { }); test('has a key with the environment name', async () => { - const element = await page.$('[data-name="test"]'); + const element = await page.$('[data-name=""]'); expect(element).toBeTruthy(); }); diff --git a/tests/src/ContextPage.test.js b/tests/src/ContextPage.test.js index a472eb14..90d0df98 100644 --- a/tests/src/ContextPage.test.js +++ b/tests/src/ContextPage.test.js @@ -41,7 +41,7 @@ describe('ContextPage', () => { test('the image key updates the open graph image with a cdn url', async () => { const text = await page.$eval('head > meta[property="og:image"]', element => element.content); - expect(text).toMatch('http://127.0.0.1:6969/image.jpg'); + expect(text).toMatch('http://localhost:6969/image.jpg'); }); test('the description key updates the open graph description', async () => { @@ -56,11 +56,11 @@ describe('ContextPage', () => { test('the canonical tag is generated if the canonical key is omitted', async () => { const text = await page.$eval('head > link[rel="canonical"]', element => element.href); - expect(text).toMatch('http://localhost:6969/context-page'); + expect(text).toMatch('https://localhost:6969/context-page'); }); test('the schema key generates a json schema', async () => { - const text = await page.$eval('head > script[type="application/ld+json"]', element => element.innerText); + const text = await page.$eval('head > script[type="application/ld+json"]', element => element.textContent); expect(text).toMatch('{"@type":"WebSite","@id":"#website","name":"Nullstack","url":"https://nullstack.app"}'); }); @@ -78,14 +78,14 @@ describe('ContextPage', () => { await page.click('button'); await page.waitForSelector('[data-event-triggered]'); const element = await page.$('[data-event-triggered]'); - expect(element).toBeTruthy(); + expect(element).toBeTruthy(); }); test('the page reacts to a server function status', async () => { await page.click('[status="401"]'); await page.waitForSelector('[data-page-status="401"]'); const element = await page.$('[data-page-status="401"]'); - expect(element).toBeTruthy(); + expect(element).toBeTruthy(); }); }); \ No newline at end of file diff --git a/tests/src/ContextProject.test.js b/tests/src/ContextProject.test.js index 7982f72f..26d0ab55 100644 --- a/tests/src/ContextProject.test.js +++ b/tests/src/ContextProject.test.js @@ -11,7 +11,7 @@ describe('ContextProject', () => { describe('ContextProject', () => { beforeAll(async () => { - await page.goto('http://localhost:6969/context-project'); + await page.goto('http://localhost:6969/context-project', { waitUntil: "networkidle0" }); await page.waitForSelector('[data-project]'); }); @@ -107,12 +107,12 @@ describe('ContextProject', () => { test('css bundle can use a cdn', async () => { const text = await page.$eval('[rel="stylesheet"]', (element) => element.href); - expect(text).toMatch(/127\.0\.0\.1:6969/); + expect(text).toMatch('localhost:6969'); }); test('javacript bundle can use a cdn', async () => { const text = await page.$eval('script[integrity]', (element) => element.src); - expect(text).toMatch(/127\.0\.0\.1:6969/); + expect(text).toMatch('localhost:6969'); }); test('css bundle has an empty integrity in dev mode', async () => { @@ -137,7 +137,7 @@ describe('robots.txt', () => { let text; beforeAll(async () => { - await page.goto('http://localhost:6969/robots.txt'); + await page.goto('http://localhost:6969/robots.txt', { waitUntil: "networkidle0" }); text = await page.evaluate(() => document.body.innerHTML); }); @@ -157,7 +157,7 @@ describe('robots.txt', () => { }); test('it reflects project.sitemap', async () => { - const index = text.indexOf('Sitemap: http://localhost:6969/sitemap.xml') + const index = text.indexOf('Sitemap: https://localhost:6969/sitemap.xml') expect(index).toBeGreaterThan(-1); }); diff --git a/tests/src/ContextWorker.njs b/tests/src/ContextWorker.njs index 8f2350ba..76f09512 100644 --- a/tests/src/ContextWorker.njs +++ b/tests/src/ContextWorker.njs @@ -16,29 +16,30 @@ class ContextWorker extends Nullstack { await this.serverFunctionName(); } - static async start({worker}) { + static async start({ worker }) { worker.enabled = true; worker.preload = ['/context-worker']; } - static async inspectHeaders({request}) { + static async inspectHeaders({ request }) { return request.headers.custom; } - async hydrate({worker}) { + async hydrate({ worker }) { worker.headers.custom = 'custom'; this.header = await this.inspectHeaders(); } - static async longServerFunction({id}) { + static async longServerFunction({ id }) { await sleep(3000); } - invokeServerFunction({id}) { - this.longServerFunction({id}); + invokeServerFunction({ worker, id }) { + this.longServerFunction({ id }); + this.didFetch = worker.fetching } - - render({worker}) { + + render({ worker }) { return (
@@ -46,13 +47,13 @@ class ContextWorker extends Nullstack {
-
+
-
id)?.join(',')}>
+
id)?.join(',')}>
{worker.registration && diff --git a/tests/src/ContextWorker.test.js b/tests/src/ContextWorker.test.js index 9043041b..bd21572f 100644 --- a/tests/src/ContextWorker.test.js +++ b/tests/src/ContextWorker.test.js @@ -15,7 +15,7 @@ describe('ContextWorker', () => { }); test('has a cdn key', async () => { - const element = await page.$('[data-cdn="http://127.0.0.1:6969"]'); + const element = await page.$('[data-cdn="http://localhost:6969"]'); expect(element).toBeTruthy(); }); @@ -74,7 +74,7 @@ describe('ContextWorker', () => { describe('ContextWorker', () => { beforeAll(async () => { - await page.goto('http://localhost:6969/context-worker'); + await page.goto('http://localhost:6969/context-worker', { waitUntil: "networkidle0" }); }); test('fetching is set to true when the worker is fetching', async () => { @@ -90,7 +90,7 @@ describe('ContextWorker', () => { describe('ContextWorker', () => { beforeAll(async () => { - await page.goto('http://localhost:6969/context-worker'); + await page.goto('http://localhost:6969/context-worker', { waitUntil: "networkidle0" }); }); test('fetching is set to the arguments of the server function when the worker is fetching', async () => { diff --git a/tests/src/DateParser.njs b/tests/src/DateParser.njs index fb118093..70e6c737 100644 --- a/tests/src/DateParser.njs +++ b/tests/src/DateParser.njs @@ -3,20 +3,20 @@ import Nullstack from 'nullstack'; class DateParser extends Nullstack { object = null; - + prepare(context) { - const date = new Date('1992-10-16'); - context.object = {date} - this.object = {date}; + const date = new Date('1992-10-16'); + context.object = { date } + this.object = { date }; } - - render({self, object}) { + + render({ object }) { return ( -
+
- {self.hydrated &&
} - {self.hydrated &&
} + {this.hydrated &&
} + {this.hydrated &&
}
) } diff --git a/tests/src/DynamicHead.njs b/tests/src/DynamicHead.njs new file mode 100644 index 00000000..9705f975 --- /dev/null +++ b/tests/src/DynamicHead.njs @@ -0,0 +1,73 @@ +import Nullstack from 'nullstack'; + +class DynamicHead extends Nullstack { + + count = 0 + negativeCount = 0 + + renderHead() { + const innerComponent = `[data-inner-component] { color: blue }` + return ( + + {this.hydrated && +
} {this.visible && } + ) } diff --git a/tests/src/StatefulComponent.test.js b/tests/src/StatefulComponent.test.js index d39d4802..ab390015 100644 --- a/tests/src/StatefulComponent.test.js +++ b/tests/src/StatefulComponent.test.js @@ -50,7 +50,7 @@ describe('StatefulComponent', () => { test('empty strings generate nodes', async () => { await page.click('[data-fill]'); await page.waitForSelector('[data-empty="not"]'); - const text = await page.$eval('[data-empty="not"]', (e) => e.innerText); + const text = await page.$eval('[data-empty="not"]', (e) => e.textContent); expect(text).toMatch('not'); }); @@ -64,4 +64,35 @@ describe('StatefulComponent', () => { expect(hasConsoleError).toBeFalsy(); }); + test('textareas with multiple nodes become a single node', async () => { + const text = await page.$eval('textarea', (e) => e.value); + expect(text).toMatch(' 1 1 '); + }); + + test('textareas with multiple nodes can be updated', async () => { + await page.click('.increment-by-one'); + await page.waitForSelector('[data-count="2"]'); + const text = await page.$eval('textarea', (e) => e.value); + expect(text).toMatch(' 2 2 '); + }); + + test('children of style become the tags html attribute', async () => { + await page.click('.increment-by-one'); + await page.waitForSelector('[data-count="2"]'); + const text = await page.$eval('button', (e) => getComputedStyle(e).backgroundColor); + expect(text).toMatch('rgba(0, 0, 0, 0.2)'); + }); + + test('attributes can prerender a zero', async () => { + await page.waitForSelector('[data-zero="0"]'); + const element = await page.$('[data-zero="0"]'); + expect(element).toBeTruthy(); + }); + + test('attributes can rererender a zero', async () => { + await page.waitForSelector('[data-hydrated-zero="0"]'); + const element = await page.$('[data-hydrated-zero="0"]'); + expect(element).toBeTruthy(); + }); + }); \ No newline at end of file diff --git a/tests/src/TextObserver.njs b/tests/src/TextObserver.njs new file mode 100644 index 00000000..91f8e061 --- /dev/null +++ b/tests/src/TextObserver.njs @@ -0,0 +1,38 @@ +import Nullstack from 'nullstack'; + +class TextObserver extends Nullstack { + + unmutatedText = 'default' + mutatedText = 'default' + + hydrate() { + const config = { + characterData: true, + childList: true, + subtree: true, + }; + const observer = new MutationObserver((mutationsList, observer) => { + for (const mutation of mutationsList) { + mutation.target.parentElement.dataset.mutated = true + } + observer.disconnect(); + }); + for (const element of [...document.querySelectorAll('[data-text-observer]')]) { + observer.observe(element, config); + } + this.mutatedText = 'mutated' + } + + render() { + return ( +
+ regular text + {this.unmutatedText} + {this.mutatedText} +
+ ) + } + +} + +export default TextObserver; \ No newline at end of file diff --git a/tests/src/TextObserver.test.js b/tests/src/TextObserver.test.js new file mode 100644 index 00000000..e2f0f31f --- /dev/null +++ b/tests/src/TextObserver.test.js @@ -0,0 +1,23 @@ +beforeAll(async () => { + await page.goto('http://localhost:6969/text-observer'); + await page.waitForSelector('[data-mutated]') +}); + +describe('TextObserver', () => { + + test('regular text should never be mutated', async () => { + const element = await page.$('[data-regular-text]:not([data-mutated])'); + expect(element).toBeTruthy(); + }); + + test('unmutated text should not be mutated', async () => { + const element = await page.$('[data-unmutated-text]:not([data-mutated])'); + expect(element).toBeTruthy(); + }); + + test('mutated text should be mutated and redundancy should be redundant', async () => { + const element = await page.$('[data-mutated-text][data-mutated]'); + expect(element).toBeTruthy(); + }); + +}); \ No newline at end of file diff --git a/tests/src/TwoWayBindings.njs b/tests/src/TwoWayBindings.njs index 7875cf08..b95e60f0 100644 --- a/tests/src/TwoWayBindings.njs +++ b/tests/src/TwoWayBindings.njs @@ -1,4 +1,5 @@ import Nullstack from 'nullstack'; +import TwoWayBindingsExternalComponent from './TwoWayBindingsExternalComponent'; class TwoWayBindings extends Nullstack { @@ -11,48 +12,94 @@ class TwoWayBindings extends Nullstack { object = { count: 1 }; array = ['a', 'b', 'c']; - parse({ event, onchange }) { + byKeyName = 'byKeyNameValue' + keyName = 'byKeyName' + + zero = 0 + + bringsHappiness = false + + external = 'external' + + debouncedBind = '69' + debouncedObject = '69' + debouncedEvent = '69' + debounceTime = 1000 + + parse({ event, source: bind, callback }) { const normalized = event.target.value.replace(',', '').padStart(3, '0'); const whole = (parseInt(normalized.slice(0, -2)) || 0).toString(); const decimal = normalized.slice(normalized.length - 2); const value = parseFloat(whole + '.' + decimal); - const bringsHappyness = value >= 1000000; - onchange({ value, bringsHappyness }); + const bringsHappiness = value >= 1000000; + bind.object[bind.property] = value + callback({ bringsHappiness }) } - renderCurrencyInput({ value, name }) { - const formatted = value.toFixed(2).replace('.', ','); - return + renderCurrencyInput({ bind, onchange }) { + const formatted = bind.object[bind.property].toFixed(2).replace('.', ','); + return + } + + renderBubble({ bind }) { + return ( + + ) } updateCharacter({ value }) { this.character = this.array[value]; } + setHappiness({ bringsHappiness }) { + this.bringsHappiness = bringsHappiness + } + + debouncedEventHandler({ event }) { + if (event.type === 'click') { + this.debouncedEvent = '6969' + } + } + render({ params }) { return ( -
+
+
- {!this.boolean &&
} - {this.number > 1 &&
} - -