From 0f580e815d63fbdbe6707ca209fb928723cb8175 Mon Sep 17 00:00:00 2001 From: Christian Mortaro Date: Sun, 8 May 2022 22:53:11 -0300 Subject: [PATCH 001/105] :zap: optimized events --- README.md | 4 +-- client/render.js | 15 ++++---- client/rerender.js | 41 ++++++++++++++++------ package.json | 2 +- plugins/bindable.js | 2 +- shared/generateTree.js | 3 +- tests/src/Application.njs | 2 ++ tests/src/ContextWorker.njs | 21 ++++++------ tests/src/OptimizedEvents.njs | 45 ++++++++++++++++++++++++ tests/src/OptimizedEvents.test.js | 57 +++++++++++++++++++++++++++++++ tests/src/TwoWayBindings.njs | 3 ++ tests/src/TwoWayBindings.test.js | 5 +++ 12 files changed, 169 insertions(+), 31 deletions(-) create mode 100644 tests/src/OptimizedEvents.njs create mode 100644 tests/src/OptimizedEvents.test.js 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/render.js b/client/render.js index 5b1eaf60..4caf762e 100644 --- a/client/render.js +++ b/client/render.js @@ -1,5 +1,6 @@ import { isFalse, isText } from '../shared/nodes'; import { anchorableElement } from './anchorableNode'; +import events from './events'; export default function render(node, options) { @@ -30,15 +31,17 @@ export default function render(node, options) { anchorableElement(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) { + const eventName = name.substring(2); + const key = node._id + '.' + eventName; + events[key] = {} + events[key].subject = node.attributes + events[key].callback = (event) => { + if (events[key].subject.default !== true) { event.preventDefault(); } - node.attributes[name]({ ...node.attributes, event }); + events[key].subject[name]({ ...events[key].subject, event }); }; - element.addEventListener(eventName, node[key]); + element.addEventListener(eventName, events[key].callback); } } else { const type = typeof (node.attributes[name]); diff --git a/client/rerender.js b/client/rerender.js index 769ffc7c..5dd99117 100644 --- a/client/rerender.js +++ b/client/rerender.js @@ -2,6 +2,16 @@ import { isFalse, isText } from '../shared/nodes'; import { anchorableElement } from './anchorableNode'; import client from './client'; import render from './render'; +import events from './events'; + +function clearEvents(node) { + if (!node?._id) return + for (const key in events) { + if (key.startsWith(node._id)) { + delete events[key] + } + } +} export default function rerender(selector, current, next) { @@ -28,6 +38,7 @@ export default function rerender(selector, current, next) { } if ((isFalse(current) || isFalse(next)) && current != next) { + clearEvents(current) const nextSelector = render(next); return selector.replaceWith(nextSelector); } @@ -37,11 +48,13 @@ export default function rerender(selector, current, next) { } if (current.type == 'head' || next.type == 'head') { + clearEvents(current) const nextSelector = render(next); return selector.replaceWith(nextSelector); } if (current.type !== next.type) { + clearEvents(current) const nextSelector = render(next); return selector.replaceWith(nextSelector); } @@ -71,17 +84,24 @@ export default function rerender(selector, current, next) { selector.value = next.attributes[name]; } } else if (name.startsWith('on')) { - const eventName = name.replace('on', ''); - const key = '_event.' + eventName; - selector.removeEventListener(eventName, current[key]); + const eventName = name.substring(2); + const key = current._id + '.' + eventName; + if (current.attributes[name] !== current.attributes[name]) { + selector.removeEventListener(eventName, events[key].callback); + delete events[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]); + if (!events[key]) { + events[key] = {} + events[key].callback = (event) => { + if (events[key].subject.default !== true) { + event.preventDefault(); + } + events[key].subject[name]({ ...events[key].subject, event }); + }; + selector.addEventListener(eventName, events[key].callback); + } + events[key].subject = next.attributes } } else { const type = typeof (next.attributes[name]); @@ -117,6 +137,7 @@ export default function rerender(selector, current, next) { rerender(selector.childNodes[i], current.children[i], next.children[i]); } for (let i = current.children.length - 1; i >= next.children.length; i--) { + console.log("remove", current._id) selector.removeChild(selector.childNodes[i]); } } else { diff --git a/package.json b/package.json index 69a307b3..fe82d7d9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nullstack", - "version": "0.15.6", + "version": "0.15.7", "description": "Full-stack Javascript Components for one-dev armies", "main": "nullstack.js", "author": "Mortaro", diff --git a/plugins/bindable.js b/plugins/bindable.js index 7d7174f2..4d1b29bd 100644 --- a/plugins/bindable.js +++ b/plugins/bindable.js @@ -44,7 +44,7 @@ function transform({ node, environment }) { } else if (node.type === 'input' && node.attributes.type === 'checkbox') { node.attributes.checked = target[node.attributes.bind]; } else { - node.attributes.value = target[node.attributes.bind] || ''; + node.attributes.value = target[node.attributes.bind] ?? ''; } node.attributes.name = node.attributes.name || node.attributes.bind; diff --git a/shared/generateTree.js b/shared/generateTree.js index 0b624e8c..a3c52b54 100644 --- a/shared/generateTree.js +++ b/shared/generateTree.js @@ -118,7 +118,8 @@ async function generateBranch(parent, node, depth, scope) { type: node.type, attributes: node.attributes || {}, instance: node.instance, - children: [] + children: [], + _id: depth.join('.') } if (node.children) { for (let i = 0; i < node.children.length; i++) { diff --git a/tests/src/Application.njs b/tests/src/Application.njs index a1426389..69823dfb 100644 --- a/tests/src/Application.njs +++ b/tests/src/Application.njs @@ -47,6 +47,7 @@ import MetatagState from './MetatagState'; import TypeScriptExtension from './TypeScriptExtension'; import JavaScriptExtension from './JavaScriptExtension'; import HydrateElement from './HydrateElement'; +import OptimizedEvents from './OptimizedEvents'; class Application extends Nullstack { @@ -117,6 +118,7 @@ class Application extends Nullstack { + ) 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/OptimizedEvents.njs b/tests/src/OptimizedEvents.njs new file mode 100644 index 00000000..5656f529 --- /dev/null +++ b/tests/src/OptimizedEvents.njs @@ -0,0 +1,45 @@ +import Nullstack from 'nullstack'; + +class OptimizedEvents extends Nullstack { + + count = 0 + + clickWhenEven() { + this.lastClick = 'even' + } + + clickWhenOdd() { + this.lastClick = 'odd' + } + + incrementCount() { + this.count++ + } + + doubleCount({ data }) { + this.count += data.count + } + + eventAfterRendered() { + this.worksAfterRender = true + } + + render() { + return ( +
+ {this.count === 1 && +
+ +
+ } + + + + +
+ ) + } + +} + +export default OptimizedEvents; \ No newline at end of file diff --git a/tests/src/OptimizedEvents.test.js b/tests/src/OptimizedEvents.test.js new file mode 100644 index 00000000..f73f84c4 --- /dev/null +++ b/tests/src/OptimizedEvents.test.js @@ -0,0 +1,57 @@ +describe('OptimizedEvents', () => { + + beforeEach(async () => { + await page.goto('http://localhost:6969/optimized-events'); + }); + + test('events are triggered', async () => { + await page.click('[data-increment-count]') + await page.waitForSelector('[data-count="1"]'); + const element = await page.$('[data-count="1"]'); + expect(element).toBeTruthy(); + }); + + test('events take the current attributes', async () => { + await page.click('[data-increment-count]') + await page.waitForSelector('[data-count="1"]'); + await page.click('[data-double-count]') + await page.waitForSelector('[data-count="2"]'); + const element = await page.$('[data-count="2"]'); + expect(element).toBeTruthy(); + }); + + test('events are triggered in elements that were added late to the dom', async () => { + await page.click('[data-increment-count]') + await page.waitForSelector('[data-after-render]'); + await page.click('[data-after-render]') + await page.waitForSelector('[data-works-after-rendered]'); + const element = await page.$('[data-works-after-rendered]'); + expect(element).toBeTruthy(); + }); + + test('inline events are triggered with current values', async () => { + await page.click('[data-increment-count]') + await page.waitForSelector('[data-count="1"]'); + await page.click('[data-set-count]') + await page.waitForSelector('[data-count="11"]'); + const element = await page.$('[data-count="11"]'); + expect(element).toBeTruthy(); + }); + + test('events can change references', async () => { + await page.click('[data-even-odd]') + await page.waitForSelector('[data-last-click="even"]'); + const element = await page.$('[data-last-click="even"]'); + expect(element).toBeTruthy(); + }); + + test('events can change references', async () => { + await page.click('[data-increment-count]') + await page.waitForSelector('[data-count="1"]'); + await page.click('[data-even-odd]') + await page.waitForSelector('[data-last-click="odd"]'); + const element = await page.$('[data-last-click="odd"]'); + expect(element).toBeTruthy(); + }); + +}); \ No newline at end of file diff --git a/tests/src/TwoWayBindings.njs b/tests/src/TwoWayBindings.njs index 7875cf08..d3b73d41 100644 --- a/tests/src/TwoWayBindings.njs +++ b/tests/src/TwoWayBindings.njs @@ -11,6 +11,8 @@ class TwoWayBindings extends Nullstack { object = { count: 1 }; array = ['a', 'b', 'c']; + zero = 0 + parse({ event, onchange }) { const normalized = event.target.value.replace(',', '').padStart(3, '0'); const whole = (parseInt(normalized.slice(0, -2)) || 0).toString(); @@ -32,6 +34,7 @@ class TwoWayBindings extends Nullstack { render({ params }) { return (
+
{!this.boolean &&
} {this.number > 1 &&
} diff --git a/tests/src/TwoWayBindings.test.js b/tests/src/TwoWayBindings.test.js index 10bb636a..568eb460 100644 --- a/tests/src/TwoWayBindings.test.js +++ b/tests/src/TwoWayBindings.test.js @@ -4,6 +4,11 @@ beforeAll(async () => { describe('ContextPage', () => { + test('inputs can be bound to zero', async () => { + const element = await page.$('[value="0"]'); + expect(element).toBeTruthy(); + }); + test('bind adds a name attribute to the element', async () => { const element = await page.$('[name="number"]'); expect(element).toBeTruthy(); From d614ca1f30e8500410baa005ecc391886283c9eb Mon Sep 17 00:00:00 2001 From: Christian Mortaro Date: Sun, 8 May 2022 22:53:19 -0300 Subject: [PATCH 002/105] :zap: optimized events --- client/events.js | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 client/events.js diff --git a/client/events.js b/client/events.js new file mode 100644 index 00000000..3f3372ad --- /dev/null +++ b/client/events.js @@ -0,0 +1,2 @@ +const events = {} +export default events \ No newline at end of file From b41d6e3c314389c3f67c2938428789a8e2263653 Mon Sep 17 00:00:00 2001 From: Christian Mortaro Date: Mon, 9 May 2022 11:45:52 -0300 Subject: [PATCH 003/105] :zap: skip removing listeners --- client/rerender.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/rerender.js b/client/rerender.js index 5dd99117..dfca6bf5 100644 --- a/client/rerender.js +++ b/client/rerender.js @@ -86,7 +86,7 @@ export default function rerender(selector, current, next) { } else if (name.startsWith('on')) { const eventName = name.substring(2); const key = current._id + '.' + eventName; - if (current.attributes[name] !== current.attributes[name]) { + if (events[key] && !current.attributes[name]) { selector.removeEventListener(eventName, events[key].callback); delete events[key] } From 235be9332932ef9b5afe409d28819956a27921d4 Mon Sep 17 00:00:00 2001 From: Christian Mortaro Date: Tue, 10 May 2022 00:13:54 -0300 Subject: [PATCH 004/105] :mute: remove console log --- client/rerender.js | 1 - 1 file changed, 1 deletion(-) diff --git a/client/rerender.js b/client/rerender.js index dfca6bf5..6b8bb890 100644 --- a/client/rerender.js +++ b/client/rerender.js @@ -137,7 +137,6 @@ export default function rerender(selector, current, next) { rerender(selector.childNodes[i], current.children[i], next.children[i]); } for (let i = current.children.length - 1; i >= next.children.length; i--) { - console.log("remove", current._id) selector.removeChild(selector.childNodes[i]); } } else { From 7c8a6d17c9000018b341b5a95f5c1ec89ad95b90 Mon Sep 17 00:00:00 2001 From: Christian Mortaro Date: Tue, 10 May 2022 01:34:03 -0300 Subject: [PATCH 005/105] :zap: weak ref events --- client/events.js | 4 ++-- client/render.js | 16 +++++++------- client/rerender.js | 35 ++++++++++--------------------- shared/generateTree.js | 3 +-- tests/src/OptimizedEvents.njs | 2 ++ tests/src/OptimizedEvents.test.js | 20 ++++++++++++++++++ 6 files changed, 44 insertions(+), 36 deletions(-) diff --git a/client/events.js b/client/events.js index 3f3372ad..f180c74d 100644 --- a/client/events.js +++ b/client/events.js @@ -1,2 +1,2 @@ -const events = {} -export default events \ No newline at end of file +export const eventCallbacks = new WeakMap() +export const eventSubjects = new WeakMap() \ No newline at end of file diff --git a/client/render.js b/client/render.js index 4caf762e..dd3a5f14 100644 --- a/client/render.js +++ b/client/render.js @@ -1,6 +1,6 @@ import { isFalse, isText } from '../shared/nodes'; import { anchorableElement } from './anchorableNode'; -import events from './events'; +import { eventCallbacks, eventSubjects } from './events' export default function render(node, options) { @@ -32,16 +32,16 @@ export default function render(node, options) { } else if (name.startsWith('on')) { if (node.attributes[name] !== undefined) { const eventName = name.substring(2); - const key = node._id + '.' + eventName; - events[key] = {} - events[key].subject = node.attributes - events[key].callback = (event) => { - if (events[key].subject.default !== true) { + const callback = (event) => { + const subject = eventSubjects.get(element) + if (subject.default !== true) { event.preventDefault(); } - events[key].subject[name]({ ...events[key].subject, event }); + subject[name]({ ...subject, event }); }; - element.addEventListener(eventName, events[key].callback); + element.addEventListener(eventName, callback); + eventCallbacks.set(element, callback) + eventSubjects.set(element, node.attributes) } } else { const type = typeof (node.attributes[name]); diff --git a/client/rerender.js b/client/rerender.js index 6b8bb890..f59a5a26 100644 --- a/client/rerender.js +++ b/client/rerender.js @@ -2,16 +2,7 @@ import { isFalse, isText } from '../shared/nodes'; import { anchorableElement } from './anchorableNode'; import client from './client'; import render from './render'; -import events from './events'; - -function clearEvents(node) { - if (!node?._id) return - for (const key in events) { - if (key.startsWith(node._id)) { - delete events[key] - } - } -} +import { eventCallbacks, eventSubjects } from './events' export default function rerender(selector, current, next) { @@ -38,7 +29,6 @@ export default function rerender(selector, current, next) { } if ((isFalse(current) || isFalse(next)) && current != next) { - clearEvents(current) const nextSelector = render(next); return selector.replaceWith(nextSelector); } @@ -48,13 +38,11 @@ export default function rerender(selector, current, next) { } if (current.type == 'head' || next.type == 'head') { - clearEvents(current) const nextSelector = render(next); return selector.replaceWith(nextSelector); } if (current.type !== next.type) { - clearEvents(current) const nextSelector = render(next); return selector.replaceWith(nextSelector); } @@ -85,23 +73,22 @@ export default function rerender(selector, current, next) { } } else if (name.startsWith('on')) { const eventName = name.substring(2); - const key = current._id + '.' + eventName; - if (events[key] && !current.attributes[name]) { - selector.removeEventListener(eventName, events[key].callback); - delete events[key] + if (eventCallbacks.has(selector) && !next.attributes[name]) { + selector.removeEventListener(eventName, eventCallbacks.get(selector)); } if (next.attributes[name]) { - if (!events[key]) { - events[key] = {} - events[key].callback = (event) => { - if (events[key].subject.default !== true) { + if (!eventCallbacks.has(selector)) { + const callback = (event) => { + const subject = eventSubjects.get(selector) + if (subject.default !== true) { event.preventDefault(); } - events[key].subject[name]({ ...events[key].subject, event }); + subject[name]({ ...subject, event }); }; - selector.addEventListener(eventName, events[key].callback); + selector.addEventListener(eventName, callback); + eventCallbacks.set(selector, callback) } - events[key].subject = next.attributes + eventSubjects.set(selector, next.attributes) } } else { const type = typeof (next.attributes[name]); diff --git a/shared/generateTree.js b/shared/generateTree.js index a3c52b54..0b624e8c 100644 --- a/shared/generateTree.js +++ b/shared/generateTree.js @@ -118,8 +118,7 @@ async function generateBranch(parent, node, depth, scope) { type: node.type, attributes: node.attributes || {}, instance: node.instance, - children: [], - _id: depth.join('.') + children: [] } if (node.children) { for (let i = 0; i < node.children.length; i++) { diff --git a/tests/src/OptimizedEvents.njs b/tests/src/OptimizedEvents.njs index 5656f529..69578727 100644 --- a/tests/src/OptimizedEvents.njs +++ b/tests/src/OptimizedEvents.njs @@ -34,6 +34,8 @@ class OptimizedEvents extends Nullstack { } + + {this.count === 0 ? : } diff --git a/tests/src/OptimizedEvents.test.js b/tests/src/OptimizedEvents.test.js index f73f84c4..8567b939 100644 --- a/tests/src/OptimizedEvents.test.js +++ b/tests/src/OptimizedEvents.test.js @@ -54,4 +54,24 @@ describe('OptimizedEvents', () => { expect(element).toBeTruthy(); }); + test('events are removed if attribute becomes undefined', async () => { + await page.click('[data-zero-only-increment]') + await page.waitForSelector('[data-count="1"]'); + await page.click('[data-zero-only-increment]') + await page.waitForTimeout(1000) + await page.waitForSelector('[data-count="1"]'); + const element = await page.$('[data-count="1"]'); + expect(element).toBeTruthy(); + }); + + test('events are removed in ternaries', async () => { + await page.click('[data-zero-nothing-increment]') + await page.waitForSelector('[data-count="1"]'); + await page.click('[data-zero-nothing-increment]') + await page.waitForTimeout(1000) + await page.waitForSelector('[data-count="1"]'); + const element = await page.$('[data-count="1"]'); + expect(element).toBeTruthy(); + }); + }); \ No newline at end of file From 3a4ad1888e036091e7d7fa0dad7ba036938863a4 Mon Sep 17 00:00:00 2001 From: Christian Mortaro Date: Thu, 12 May 2022 18:16:48 -0300 Subject: [PATCH 006/105] :construction: optimizing renderers --- client/client.js | 4 +- client/index.js | 5 +- client/render.js | 29 ++--- client/rerender.js | 205 ++++++++++++++++++++----------- server/render.js | 38 +++--- shared/generateTree.js | 17 ++- shared/nodes.js | 2 +- tests/.env | 9 ++ tests/jest-puppeteer.config.js | 3 + tests/src/TwoWayBindings.test.js | 2 +- 10 files changed, 200 insertions(+), 114 deletions(-) create mode 100644 tests/.env diff --git a/client/client.js b/client/client.js index 798dfce9..2d8668bc 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 @@ -31,7 +30,7 @@ client.update = async function update() { client.initialized = false client.renewalQueue = [] client.nextVirtualDom = await generateTree(client.initializer(), scope) - rerender(client.selector) + rerender() client.virtualDom = client.nextVirtualDom client.nextVirtualDom = null client.processLifecycleQueues() @@ -42,7 +41,6 @@ client.update = async function update() { client.processLifecycleQueues = async function processLifecycleQueues() { if (!client.initialized) { client.initialized = true - client.hydrated = true } let shouldUpdate = false while (client.initiationQueue.length) { diff --git a/client/index.js b/client/index.js index 2777dd63..005f58c6 100644 --- a/client/index.js +++ b/client/index.js @@ -14,7 +14,7 @@ import page from './page'; import params, { updateParams } from './params'; import project from './project'; import render from './render'; -import rerender from './rerender'; +import rerender, { hydrate } from './rerender'; import router from './router'; import settings from './settings'; import worker from './worker'; @@ -63,12 +63,13 @@ export default class Nullstack { client.selector = body } else { client.virtualDom = await generateTree(client.initializer(), scope); + hydrate(client.selector, client.virtualDom) 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); + rerender(); client.virtualDom = client.nextVirtualDom; client.nextVirtualDom = null; } diff --git a/client/render.js b/client/render.js index 5b1eaf60..7564b2cf 100644 --- a/client/render.js +++ b/client/render.js @@ -4,30 +4,31 @@ import { anchorableElement } from './anchorableNode'; 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; + node.instance._self.element = node.element; } for (let name in node.attributes) { if (name === 'html') { - element.innerHTML = node.attributes[name]; - anchorableElement(element); + node.element.innerHTML = node.attributes[name]; + node.attributes['data-n'] === undefined && anchorableElement(node.element); } else if (name.startsWith('on')) { if (node.attributes[name] !== undefined) { const eventName = name.replace('on', ''); @@ -38,15 +39,15 @@ export default function render(node, options) { } node.attributes[name]({ ...node.attributes, event }); }; - element.addEventListener(eventName, node[key]); + node.element.addEventListener(eventName, node[key]); } } else { const type = typeof (node.attributes[name]); if (type !== 'object' && type !== 'function') { if (name != 'value' && node.attributes[name] === true) { - element.setAttribute(name, ''); + node.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]); + node.element.setAttribute(name, node.attributes[name]); } } } @@ -55,14 +56,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; + 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..45fd9fbd 100644 --- a/client/rerender.js +++ b/client/rerender.js @@ -1,44 +1,125 @@ -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) { +function updateAttributes(selector, current, next) { + 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]; + } + next.attributes['data-n'] === undefined && 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]); + } + } + } + } + } +} + +function _rerender(current, next) { - current = current === undefined ? client.virtualDom : current; - next = next === undefined ? client.nextVirtualDom : next; + const selector = current.element + next.element = current.element 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('')); - } - if (element.COMMENT_NODE === 8 && element.textContent === '#') { - selector.removeChild(element); - } - } - } - if (isFalse(current) && isFalse(next)) { return; } if ((isFalse(current) || isFalse(next)) && current != next) { const nextSelector = render(next); - return selector.replaceWith(nextSelector); + selector.replaceWith(nextSelector); + if (current.type !== 'head' && next.type !== 'head') { + return + } } - if (current.type == 'head' && next.type == 'head') { - return; + if (current.type == 'head' ^ next.type == 'head') { + const nextSelector = render(next); + selector.replaceWith(nextSelector); } - if (current.type == 'head' || next.type == 'head') { - const nextSelector = render(next); - return selector.replaceWith(nextSelector); + if (current.type !== 'head' && next.type === 'head') { + const limit = next.children.length; + for (let i = limit - 1; i > -1; i--) { + const nextSelector = render(next.children[i]); + document.querySelector('head').appendChild(nextSelector) + } + return + } + + if (current.type === 'head' && next.type !== 'head') { + const limit = current.children.length; + for (let i = limit - 1; i > -1; i--) { + document.querySelector(`[data-n="${current.children[i].attributes['data-n']}"]`).remove() + } + return + } + + if (next.type === 'head') { + const limit = Math.max(current.children.length, next.children.length); + for (let i = limit - 1; i > -1; i--) { + if (isUndefined(current.children[i]) && !isFalse(next.children[i])) { + const nextSelector = render(next.children[i]); + document.querySelector('head').appendChild(nextSelector) + } else if (isUndefined(next.children[i]) && !isFalse(current.children[i])) { + current.children[i].element.remove() + } else if (!isFalse(current.children[i]) && !isFalse(next.children[i])) { + if (current.children[i].type === next.children[i].type) { + next.children[i].element = current.children[i].element + const element = document.querySelector(`[data-n="${next.children[i].attributes['data-n']}"]`) + updateAttributes(element, current.children[i], next.children[i]) + } else { + document.querySelector(`[data-n="${current.children[i].attributes['data-n']}"]`).remove() + const nextSelector = render(next.children[i]); + document.querySelector('head').appendChild(nextSelector) + } + } else if (isFalse(current.children[i]) && !isFalse(next.children[i])) { + const nextSelector = render(next.children[i]); + document.querySelector('head').appendChild(nextSelector) + } else { + current.children[i].element.remove() + } + } + return } if (current.type !== next.type) { @@ -48,65 +129,20 @@ export default function rerender(selector, current, next) { if (isText(current) && isText(next)) { if (current != next) { - selector.nodeValue = next; + selector.nodeValue = 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]); - } - } - } - } - } + updateAttributes(selector, current, next) if (next.attributes.html) return; 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,7 +150,7 @@ 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]); @@ -129,12 +165,12 @@ export default function rerender(selector, current, next) { 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(""); + selector.value = next.children.reduce((t, c) => t + c.text, ''); } if (next.type == 'select') { @@ -143,4 +179,29 @@ export default function rerender(selector, current, next) { } +} + +export default function rerender() { + _rerender(client.virtualDom, client.nextVirtualDom) +} + +export function hydrate(selector, node) { + if (node?.attributes?.['data-n'] !== undefined) { + node.element = document.querySelector(`[data-n="${node.attributes['data-n']}"]`) + return + } + node.element = selector + for (const element of selector.childNodes) { + if (element.tagName && element.tagName.toLowerCase() == 'textarea' && element.childNodes.length == 0) { + element.appendChild(document.createTextNode('')); + } + if (element.COMMENT_NODE === 8 && element.textContent === '#') { + selector.removeChild(element); + } + } + if (!node.children) return + const limit = node.children.length; + for (let i = limit - 1; i > -1; i--) { + hydrate(selector.childNodes[i], node.children[i]) + } } \ No newline at end of file diff --git a/server/render.js b/server/render.js index c9e3a994..2d6b19b1 100644 --- a/server/render.js +++ b/server/render.js @@ -1,25 +1,25 @@ import { isFalse } from "../shared/nodes"; -import {sanitizeHtml} from "../shared/sanitizeString"; +import { sanitizeHtml } from "../shared/sanitizeString"; export default function render(node, scope) { - if(isFalse(node)) { + if (isFalse(node)) { return ""; } - if(node.type === undefined) { - return (sanitizeHtml(node.toString()) || ' ') + ""; + if (node.type === 'text') { + return (sanitizeHtml(node.text.toString()) || ' ') + ""; } 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) { + 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)) { + } else if (name == 'value' || (node.attributes[name] !== false && node.attributes[name] !== null && node.attributes[name] !== undefined)) { element += ` ${name}="${node.attributes[name]}"`; } } @@ -27,24 +27,24 @@ export default function render(node, scope) { } 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) { + if (selfClosing && node.children.length === 0) { element += '/>'; } else { element += '>'; - if(node.attributes.html) { + if (node.attributes.html) { const source = node.attributes.html; - if(node.type === 'head') { + if (node.type === 'head') { scope.head += source; } else { element += source; } - } else if(node.type === 'textarea') { - element += node.children[0]; + } else if (node.type === 'textarea') { + element += node.children[0].text; } else { - for(let i = 0; i < node.children.length; i++) { + for (let i = 0; i < node.children.length; i++) { const source = render(node.children[i], scope); - if(node.type === 'head') { + if (node.type === 'head') { scope.head += source; } else { element += source; @@ -53,6 +53,6 @@ export default function render(node, scope) { } element += ``; } - - return node.type === 'head' ? '' : element; + + return node.type === 'head' ? '' : element; } \ No newline at end of file diff --git a/shared/generateTree.js b/shared/generateTree.js index 0b624e8c..b5603e3f 100644 --- a/shared/generateTree.js +++ b/shared/generateTree.js @@ -18,7 +18,10 @@ async function generateBranch(parent, node, depth, scope) { } if (isFalse(node)) { - parent.children.push(false); + parent.children.push({ + type: false, + attributes: {} + }); return; } @@ -124,12 +127,22 @@ async function generateBranch(parent, node, depth, scope) { for (let i = 0; i < node.children.length; i++) { await generateBranch(branch, node.children[i], [...depth, i], scope); } + if (node.type === 'head') { + for (let i = 0; i < branch.children.length; i++) { + if (branch.children[i].attributes) { + branch.children[i].attributes['data-n'] = [...depth, i].join('.') + } + } + } } parent.children.push(branch); return; } - parent.children.push(node); + parent.children.push({ + type: 'text', + text: node, + }); } diff --git a/shared/nodes.js b/shared/nodes.js index b7490a47..382ba7fd 100644 --- a/shared/nodes.js +++ b/shared/nodes.js @@ -17,5 +17,5 @@ export function isFunction(node) { } export function isText(node) { - return typeof (node.children) === 'undefined'; + return node.type === 'text' } \ No newline at end of file diff --git a/tests/.env b/tests/.env new file mode 100644 index 00000000..fe46256e --- /dev/null +++ b/tests/.env @@ -0,0 +1,9 @@ +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://127.0.0.1:6969' +NULLSTACK_WORKER_API='http://127.0.0.1:6969' \ No newline at end of file diff --git a/tests/jest-puppeteer.config.js b/tests/jest-puppeteer.config.js index e9a1eb35..3495c82a 100644 --- a/tests/jest-puppeteer.config.js +++ b/tests/jest-puppeteer.config.js @@ -2,6 +2,9 @@ const CI = !!process.env.CI; const baseOptions = { + launch: { + headless: true + }, server: { command: 'npm run start', port: 6969, diff --git a/tests/src/TwoWayBindings.test.js b/tests/src/TwoWayBindings.test.js index 10bb636a..ebdd0415 100644 --- a/tests/src/TwoWayBindings.test.js +++ b/tests/src/TwoWayBindings.test.js @@ -2,7 +2,7 @@ beforeAll(async () => { await page.goto('http://localhost:6969/two-way-bindings?page=1'); }); -describe('ContextPage', () => { +describe('TwoWayBindings', () => { test('bind adds a name attribute to the element', async () => { const element = await page.$('[name="number"]'); From 7e4034cb3bb5876602392601f3763c0b98a0bb01 Mon Sep 17 00:00:00 2001 From: Christian Mortaro Date: Thu, 12 May 2022 19:38:26 -0300 Subject: [PATCH 007/105] :construction: fixing render with element --- client/render.js | 8 ++++---- tests/.env | 9 --------- 2 files changed, 4 insertions(+), 13 deletions(-) delete mode 100644 tests/.env diff --git a/client/render.js b/client/render.js index 30b4ef54..367edff1 100644 --- a/client/render.js +++ b/client/render.js @@ -34,15 +34,15 @@ export default function render(node, options) { if (node.attributes[name] !== undefined) { const eventName = name.substring(2); const callback = (event) => { - const subject = eventSubjects.get(element) + const subject = eventSubjects.get(node.element) if (subject.default !== true) { event.preventDefault(); } subject[name]({ ...subject, event }); }; - element.addEventListener(eventName, callback); - eventCallbacks.set(element, callback) - eventSubjects.set(element, node.attributes) + node.element.addEventListener(eventName, callback); + eventCallbacks.set(node.element, callback) + eventSubjects.set(node.element, node.attributes) } } else { const type = typeof (node.attributes[name]); diff --git a/tests/.env b/tests/.env deleted file mode 100644 index fe46256e..00000000 --- a/tests/.env +++ /dev/null @@ -1,9 +0,0 @@ -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://127.0.0.1:6969' -NULLSTACK_WORKER_API='http://127.0.0.1:6969' \ No newline at end of file From c9aa16f88dd0fd2ea2602f0e78105d6b5b8a9adf Mon Sep 17 00:00:00 2001 From: Christian Mortaro Date: Fri, 13 May 2022 01:16:51 -0300 Subject: [PATCH 008/105] :white_check_mark: tests for dynamic head --- tests/src/Application.njs | 2 ++ tests/src/DynamicHead.njs | 52 +++++++++++++++++++++++++++ tests/src/DynamicHead.test.js | 67 +++++++++++++++++++++++++++++++++++ 3 files changed, 121 insertions(+) create mode 100644 tests/src/DynamicHead.njs create mode 100644 tests/src/DynamicHead.test.js diff --git a/tests/src/Application.njs b/tests/src/Application.njs index 69823dfb..e865fc8d 100644 --- a/tests/src/Application.njs +++ b/tests/src/Application.njs @@ -48,6 +48,7 @@ import TypeScriptExtension from './TypeScriptExtension'; import JavaScriptExtension from './JavaScriptExtension'; import HydrateElement from './HydrateElement'; import OptimizedEvents from './OptimizedEvents'; +import DynamicHead from './DynamicHead' class Application extends Nullstack { @@ -119,6 +120,7 @@ class Application extends Nullstack { + ) diff --git a/tests/src/DynamicHead.njs b/tests/src/DynamicHead.njs new file mode 100644 index 00000000..7a919d5b --- /dev/null +++ b/tests/src/DynamicHead.njs @@ -0,0 +1,52 @@ +import Nullstack from 'nullstack'; + +class DynamicHead extends Nullstack { + + count = 0 + + renderHead() { + const innerComponent = `[data-inner-component] { color: blue }` + return ( + ) } From 9ed196cdf5208b1d3c40294b808a87325a4de983 Mon Sep 17 00:00:00 2001 From: Christian Mortaro Date: Sun, 15 May 2022 17:44:14 -0300 Subject: [PATCH 025/105] :adhesive_bandage: small style patches --- shared/element.js | 2 +- shared/generateTree.js | 2 +- tests/src/BodyFragment.test.js | 30 ++++++++++++++++++++++++++++++ tests/src/DynamicHead.njs | 2 +- tests/src/DynamicHead.test.js | 5 +++++ 5 files changed, 38 insertions(+), 3 deletions(-) create mode 100644 tests/src/BodyFragment.test.js diff --git a/shared/element.js b/shared/element.js index 1b28ae34..75ff247a 100644 --- a/shared/element.js +++ b/shared/element.js @@ -12,7 +12,7 @@ export default function element(type, props, ...children) { children = [children.join('')]; } const attributes = { ...props, children }; - if (type === 'style') { + if (type === 'style' && !attributes.html) { attributes.html = children.join(''); } if (type === 'element') { diff --git a/shared/generateTree.js b/shared/generateTree.js index 0b7caecb..92847a91 100644 --- a/shared/generateTree.js +++ b/shared/generateTree.js @@ -157,7 +157,7 @@ async function generateBranch(parent, node, depth, scope) { for (let i = 0; i < branch.children.length; i++) { if (branch.children[i].attributes) { branch.children[i].head = true - branch.children[i].attributes.id = depth + '-' + i + branch.children[i].attributes.id ??= depth + '-' + i } } } diff --git a/tests/src/BodyFragment.test.js b/tests/src/BodyFragment.test.js new file mode 100644 index 00000000..d59a21f3 --- /dev/null +++ b/tests/src/BodyFragment.test.js @@ -0,0 +1,30 @@ +import Nullstack from 'nullstack'; + +class CommponentA extends Nullstack { + render() { + return

A

+ } +} + +class CommponentB extends Nullstack { + render() { + return

B

+ } +} + +class ComponentTernary extends Nullstack { + + showA = true + + render() { + return ( +
+ {this.showA ? : } + +
+ ) + } + +} + +export default ComponentTernary; \ No newline at end of file diff --git a/tests/src/DynamicHead.njs b/tests/src/DynamicHead.njs index 9b2f1d56..4c791fdd 100644 --- a/tests/src/DynamicHead.njs +++ b/tests/src/DynamicHead.njs @@ -49,7 +49,7 @@ class DynamicHead extends Nullstack { data-inner-component {this.count % 2 === 0 ? - + : not head }
diff --git a/tests/src/DynamicHead.test.js b/tests/src/DynamicHead.test.js index 12f48873..8d84e934 100644 --- a/tests/src/DynamicHead.test.js +++ b/tests/src/DynamicHead.test.js @@ -63,6 +63,11 @@ describe('DynamicHead', () => { expect(element).toBeTruthy(); }); + test('head elements can have custom ids', async () => { + const element = await page.$('#ternary-head'); + expect(element).toBeTruthy(); + }); + test('the head tag accepts dynamic lists of increasing size', async () => { for (let i = 1; i < 3; i++) { await page.click('[data-increment]'); From 152815d5e533563a3168ed10306c9f3dffb5cfaf Mon Sep 17 00:00:00 2001 From: Christian Mortaro Date: Sun, 15 May 2022 21:39:04 -0300 Subject: [PATCH 026/105] :white_check_mark: body fragment tests --- client/index.js | 1 + tests/src/BodyFragment.njs | 7 +-- tests/src/BodyFragment.test.js | 81 ++++++++++++++++++++++++---------- 3 files changed, 63 insertions(+), 26 deletions(-) diff --git a/client/index.js b/client/index.js index 4f684cda..0421f355 100644 --- a/client/index.js +++ b/client/index.js @@ -65,6 +65,7 @@ export default class Nullstack { client.selector = body } else { client.virtualDom = await generateTree(client.initializer(), scope); + client.currentBody = client.nextBody hydrate(client.selector, client.virtualDom) context.environment = environment; scope.plugins = loadPlugins(scope); diff --git a/tests/src/BodyFragment.njs b/tests/src/BodyFragment.njs index e78d3495..39d965fb 100644 --- a/tests/src/BodyFragment.njs +++ b/tests/src/BodyFragment.njs @@ -18,10 +18,10 @@ class BodyFragment extends Nullstack { this.hasDataKeys = Object.keys(data).length > 0 } - render() { + render({ self }) { return ( - - + + BodyFragment {this.visible && @@ -29,6 +29,7 @@ class BodyFragment extends Nullstack { BodyFragment } + home ) } diff --git a/tests/src/BodyFragment.test.js b/tests/src/BodyFragment.test.js index d59a21f3..e5dd4359 100644 --- a/tests/src/BodyFragment.test.js +++ b/tests/src/BodyFragment.test.js @@ -1,30 +1,65 @@ -import Nullstack from 'nullstack'; +beforeEach(async () => { + await page.goto('http://localhost:6969/body-fragment'); +}); -class CommponentA extends Nullstack { - render() { - return

A

- } -} +describe('BodyFragment', () => { -class CommponentB extends Nullstack { - render() { - return

B

- } -} + test('the body behaves as a fragment and creates no markup', async () => { + const element = await page.$('body > #application > h1'); + expect(element).toBeTruthy(); + }); -class ComponentTernary extends Nullstack { + 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(); + }); - showA = true + 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(); + }); - render() { - return ( -
- {this.showA ? : } - -
- ) - } + 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(); + }); -export default ComponentTernary; \ No newline at end of file + 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('body has access to plugins that have export a transformBody', async () => { + await page.waitForSelector('body[data-hydrated]') + await page.click('body'); + await page.waitForSelector('[data-keys]'); + const element = await page.$('[data-keys]'); + 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 From 4aaa80569f8e4a2b294ac1dff3c7b418112c9bb0 Mon Sep 17 00:00:00 2001 From: Christian Mortaro Date: Sun, 15 May 2022 21:50:30 -0300 Subject: [PATCH 027/105] :white_check_mark: tests for style children --- tests/src/StatefulComponent.test.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/src/StatefulComponent.test.js b/tests/src/StatefulComponent.test.js index 8d1d38f2..7814beb1 100644 --- a/tests/src/StatefulComponent.test.js +++ b/tests/src/StatefulComponent.test.js @@ -76,4 +76,11 @@ describe('StatefulComponent', () => { 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)'); + }); + }); \ No newline at end of file From d4f0cbd5e16d2855107e9b0470d4e2f44cf87fc1 Mon Sep 17 00:00:00 2001 From: Christian Mortaro Date: Sun, 15 May 2022 22:13:06 -0300 Subject: [PATCH 028/105] :white_check_mark: tests for array attributes --- tests/src/ArrayAttributes.njs | 2 +- tests/src/ArrayAttributes.test.js | 81 +++++++++++++++++++++++++++++++ tests/src/BodyFragment.test.js | 5 ++ 3 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 tests/src/ArrayAttributes.test.js diff --git a/tests/src/ArrayAttributes.njs b/tests/src/ArrayAttributes.njs index e4e19e2e..6593411b 100644 --- a/tests/src/ArrayAttributes.njs +++ b/tests/src/ArrayAttributes.njs @@ -28,7 +28,7 @@ class ArrayAttributes extends Nullstack { - +
) } diff --git a/tests/src/ArrayAttributes.test.js b/tests/src/ArrayAttributes.test.js new file mode 100644 index 00000000..86fc6426 --- /dev/null +++ b/tests/src/ArrayAttributes.test.js @@ -0,0 +1,81 @@ +beforeAll(async () => { + await page.goto('http://localhost:6969/array-attributes'); +}); + +describe('ArrayAttributes jsx', () => { + + 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(); + }); + +}); diff --git a/tests/src/BodyFragment.test.js b/tests/src/BodyFragment.test.js index e5dd4359..90fa0a99 100644 --- a/tests/src/BodyFragment.test.js +++ b/tests/src/BodyFragment.test.js @@ -19,6 +19,11 @@ describe('BodyFragment', () => { 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'); From a0980198f2be69b68606ef1223a0f82853e74bbf Mon Sep 17 00:00:00 2001 From: Christian Mortaro Date: Mon, 16 May 2022 20:40:57 -0300 Subject: [PATCH 029/105] :white_check_mark: advanced anchor tests --- client/events.js | 10 ++++++++- plugins/anchorable.js | 16 ++------------ tests/src/AnchorModifiers.njs | 36 +++++++++++++++++++++++++------ tests/src/AnchorModifiers.test.js | 33 ++++++++++++++++++++++++---- tests/src/Application.njs | 2 +- 5 files changed, 70 insertions(+), 27 deletions(-) diff --git a/client/events.js b/client/events.js index 36e14753..603f605a 100644 --- a/client/events.js +++ b/client/events.js @@ -1,3 +1,5 @@ +import router from './router' + export const eventCallbacks = new WeakMap() export const eventSubjects = new WeakMap() @@ -12,9 +14,15 @@ function executeEvent(callback, subject, event) { export function generateCallback(selector, name) { return function eventCallback(event) { const subject = eventSubjects.get(selector) - if (subject.default !== true) { + 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(); } + if (subject[name] === true) return if (Array.isArray(subject[name])) { for (const subcallback of subject[name]) { executeEvent(subcallback, subject, event) diff --git a/plugins/anchorable.js b/plugins/anchorable.js index d3f92925..f2b9351f 100644 --- a/plugins/anchorable.js +++ b/plugins/anchorable.js @@ -8,21 +8,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 ??= true } export default { transform, client: true } diff --git a/tests/src/AnchorModifiers.njs b/tests/src/AnchorModifiers.njs index 1306e73f..e982d5ac 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({ self }) { + self.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({ self }) { 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 b1ae62e7..db7d649c 100644 --- a/tests/src/Application.njs +++ b/tests/src/Application.njs @@ -117,7 +117,7 @@ class Application extends Nullstack { - + From 31762f9cd73f4f178fe5043f6ea08b5b30c8e707 Mon Sep 17 00:00:00 2001 From: Christian Mortaro Date: Fri, 20 May 2022 22:34:53 -0300 Subject: [PATCH 030/105] :fire: remove hydration from spa --- client/index.js | 1 - 1 file changed, 1 deletion(-) diff --git a/client/index.js b/client/index.js index 0421f355..4b36a657 100644 --- a/client/index.js +++ b/client/index.js @@ -59,7 +59,6 @@ export default class Nullstack { typeof context.start === 'function' && await context.start(context); context.environment = environment; client.virtualDom = await generateTree(client.initializer(), scope); - hydrate(client.selector, client.virtualDom); const body = render(client.virtualDom); client.selector.replaceWith(body); client.selector = body From a0421bd195a1700f3ba34d4cc5ba99ef95e244b5 Mon Sep 17 00:00:00 2001 From: Christian Mortaro Date: Sat, 21 May 2022 00:08:53 -0300 Subject: [PATCH 031/105] :sparkles: spa dynamic head --- client/rerender.js | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/client/rerender.js b/client/rerender.js index 79c72f3b..afdc2a69 100644 --- a/client/rerender.js +++ b/client/rerender.js @@ -8,6 +8,12 @@ import generateTruthyString from '../shared/generateTruthyString'; const head = document.head const body = document.body +function getElement(node) { + if (node.element) return node.element + node.element = document.getElementById(node.attributes.id) + return node.element +} + function updateAttributes(selector, currentAttributes, nextAttributes) { const attributeNames = Object.keys({ ...currentAttributes, ...nextAttributes }); for (const name of attributeNames) { @@ -81,7 +87,7 @@ function _rerender(current, next) { return; } - if ((isFalse(current) || isFalse(next)) && current != next) { + if ((isFalse(current) || isFalse(next)) && current.type != next.type) { const nextSelector = render(next); selector.replaceWith(nextSelector); if (current.type !== 'head' && next.type !== 'head') { @@ -91,7 +97,7 @@ function _rerender(current, next) { if (current.type === 'head' ^ next.type === 'head') { const nextSelector = render(next); - selector.replaceWith(nextSelector); + getElement(current).replaceWith(nextSelector); } if (current.type !== 'head' && next.type === 'head') { @@ -106,7 +112,7 @@ function _rerender(current, next) { if (current.type === 'head' && next.type !== 'head') { const limit = current.children.length; for (let i = limit - 1; i > -1; i--) { - current.children[i].element.remove() + getElement(current.children[i]).remove() } return } @@ -118,13 +124,13 @@ function _rerender(current, next) { const nextSelector = render(next.children[i]); head.appendChild(nextSelector) } else if (isUndefined(next.children[i]) && !isFalse(current.children[i])) { - current.children[i].element.remove() + getElement(current.children[i]).remove() } else if (!isFalse(current.children[i]) && !isFalse(next.children[i])) { if (current.children[i].type === next.children[i].type) { - next.children[i].element = current.children[i].element - updateAttributes(current.children[i].element, current.children[i].attributes, next.children[i].attributes) + next.children[i].element = getElement(current.children[i]) + updateAttributes(next.children[i].element, current.children[i].attributes, next.children[i].attributes) } else { - current.children[i].element.remove() + getElement(current.children[i]).remove() const nextSelector = render(next.children[i]); head.appendChild(nextSelector) } @@ -132,7 +138,7 @@ function _rerender(current, next) { const nextSelector = render(next.children[i]); head.appendChild(nextSelector) } else if (current.children[i].type) { - current.children[i].element.remove() + getElement(current.children[i]).remove() } } return From 3eb376b00589a4b4b4cefdcbcb84819187502298 Mon Sep 17 00:00:00 2001 From: Christian Mortaro Date: Sat, 21 May 2022 23:39:45 -0300 Subject: [PATCH 032/105] :zap: faster spa hydration --- client/hydrate.js | 7 ++++++ client/rerender.js | 22 ++++++----------- tests/src/Application.njs | 2 ++ tests/src/ArrayAttributes.njs | 4 ++-- tests/src/ArrayAttributes.test.js | 1 + tests/src/ErrorOnChildNode.njs | 12 +++++----- tests/src/ErrorOnChildNode.test.js | 38 ++++++++++++++++++++++++++++-- 7 files changed, 61 insertions(+), 25 deletions(-) diff --git a/client/hydrate.js b/client/hydrate.js index 040e1d75..d73e11ec 100644 --- a/client/hydrate.js +++ b/client/hydrate.js @@ -19,6 +19,13 @@ export default function hydrate(selector, node) { 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.') + } hydrate(selector.childNodes[i], node.children[i]) } } \ No newline at end of file diff --git a/client/rerender.js b/client/rerender.js index afdc2a69..0dc91928 100644 --- a/client/rerender.js +++ b/client/rerender.js @@ -15,6 +15,7 @@ function getElement(node) { } function updateAttributes(selector, currentAttributes, nextAttributes) { + if (!selector) return const attributeNames = Object.keys({ ...currentAttributes, ...nextAttributes }); for (const name of attributeNames) { if (name === 'html') { @@ -88,8 +89,8 @@ function _rerender(current, next) { } if ((isFalse(current) || isFalse(next)) && current.type != next.type) { - const nextSelector = render(next); - selector.replaceWith(nextSelector); + current.element = render(next); + selector.replaceWith(current.element); if (current.type !== 'head' && next.type !== 'head') { return } @@ -112,7 +113,7 @@ function _rerender(current, next) { if (current.type === 'head' && next.type !== 'head') { const limit = current.children.length; for (let i = limit - 1; i > -1; i--) { - getElement(current.children[i]).remove() + getElement(current.children[i])?.remove?.() } return } @@ -124,13 +125,13 @@ function _rerender(current, next) { const nextSelector = render(next.children[i]); head.appendChild(nextSelector) } else if (isUndefined(next.children[i]) && !isFalse(current.children[i])) { - getElement(current.children[i]).remove() + getElement(current.children[i])?.remove?.() } else if (!isFalse(current.children[i]) && !isFalse(next.children[i])) { if (current.children[i].type === next.children[i].type) { next.children[i].element = getElement(current.children[i]) updateAttributes(next.children[i].element, current.children[i].attributes, next.children[i].attributes) } else { - getElement(current.children[i]).remove() + getElement(current.children[i])?.remove?.() const nextSelector = render(next.children[i]); head.appendChild(nextSelector) } @@ -138,7 +139,7 @@ function _rerender(current, next) { const nextSelector = render(next.children[i]); head.appendChild(nextSelector) } else if (current.children[i].type) { - getElement(current.children[i]).remove() + getElement(current.children[i])?.remove?.() } } return @@ -160,7 +161,6 @@ function _rerender(current, next) { updateAttributes(selector, current.attributes, next.attributes) if (next.attributes.html) return; - 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++) { @@ -179,14 +179,6 @@ function _rerender(current, next) { } } 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(current.children[i], next.children[i]); } } diff --git a/tests/src/Application.njs b/tests/src/Application.njs index db7d649c..ae90b5f8 100644 --- a/tests/src/Application.njs +++ b/tests/src/Application.njs @@ -75,6 +75,8 @@ class Application extends Nullstack { undefined nodes lifecycle hydrate element + error-on-child-node?dom=true + error-on-child-node?serialization=true
diff --git a/tests/src/ArrayAttributes.njs b/tests/src/ArrayAttributes.njs index 6593411b..6e9e42c0 100644 --- a/tests/src/ArrayAttributes.njs +++ b/tests/src/ArrayAttributes.njs @@ -15,9 +15,9 @@ class ArrayAttributes extends Nullstack { this.count += this.count } - render() { + render({ self }) { return ( -
+
{JSON.stringify(this.classes)} {JSON.stringify(this.styles)} diff --git a/tests/src/ArrayAttributes.test.js b/tests/src/ArrayAttributes.test.js index 86fc6426..ff997893 100644 --- a/tests/src/ArrayAttributes.test.js +++ b/tests/src/ArrayAttributes.test.js @@ -1,5 +1,6 @@ beforeAll(async () => { await page.goto('http://localhost:6969/array-attributes'); + await page.waitForSelector('[data-hydrated]') }); describe('ArrayAttributes jsx', () => { diff --git a/tests/src/ErrorOnChildNode.njs b/tests/src/ErrorOnChildNode.njs index 7ff32ce5..86b9a77c 100644 --- a/tests/src/ErrorOnChildNode.njs +++ b/tests/src/ErrorOnChildNode.njs @@ -14,14 +14,14 @@ class ObjectId { class ErrorOnChildNode extends Nullstack { - testValue = 'initial Value'; + value = 'initial Value'; records = [ { _id: new ObjectId('a') }, ] testClick() { - this.testValue = 'Changed Value'; + this.value = 'Changed Value'; } renderSerializationError() { @@ -61,13 +61,13 @@ class ErrorOnChildNode extends Nullstack { render({ params }) { return ( <> -

Table Error

+

Table Error

{params.serialization && } {params.dom && } -
- {this.testValue} +
+ {this.value}
- + ); } diff --git a/tests/src/ErrorOnChildNode.test.js b/tests/src/ErrorOnChildNode.test.js index 9e34a577..ead928cd 100644 --- a/tests/src/ErrorOnChildNode.test.js +++ b/tests/src/ErrorOnChildNode.test.js @@ -1,4 +1,4 @@ -describe('ErrorOnChildNode dom', () => { +describe('ErrorOnChildNode dom ssr', () => { let error; @@ -15,7 +15,7 @@ describe('ErrorOnChildNode dom', () => { }) -describe('ErrorOnChildNode serialization', () => { +describe('ErrorOnChildNode serialization ssr', () => { let error; @@ -30,4 +30,38 @@ describe('ErrorOnChildNode serialization', () => { }) }); +}) + +describe('ErrorOnChildNode dom spa', () => { + + beforeAll(async () => { + await page.goto('http://localhost:6969/'); + }); + + test('hydration errors related to dom should not happen in spa mode', async () => { + await page.click('[href="/error-on-child-node?dom=true"]'); + await page.waitForSelector('[data-dom-error]'); + await page.click('[data-dom-error]'); + await page.waitForSelector('[data-value="Changed Value"]'); + const element = await page.$('[data-value="Changed Value"]'); + expect(element).toBeTruthy(); + }); + +}) + +describe('ErrorOnChildNode dom spa', () => { + + beforeAll(async () => { + await page.goto('http://localhost:6969/'); + }); + + test('hydration errors related to dom should not happen in spa mode', async () => { + await page.click('[href="/error-on-child-node?serialization=true"]'); + await page.waitForSelector('[data-dom-error]'); + await page.click('[data-dom-error]'); + await page.waitForSelector('[data-value="Changed Value"]'); + const element = await page.$('[data-value="Changed Value"]'); + expect(element).toBeTruthy(); + }); + }) \ No newline at end of file From 908ecaf426d29067cdd804760d4dc65191332fe0 Mon Sep 17 00:00:00 2001 From: Christian Mortaro Date: Sun, 22 May 2022 03:53:14 -0300 Subject: [PATCH 033/105] :zap: faster head ternaries --- client/rerender.js | 65 ++++++++++++++++++----------------- tests/src/DynamicHead.njs | 12 +++++++ tests/src/DynamicHead.test.js | 32 +++++++++++++++++ 3 files changed, 78 insertions(+), 31 deletions(-) diff --git a/client/rerender.js b/client/rerender.js index 0dc91928..2e85670f 100644 --- a/client/rerender.js +++ b/client/rerender.js @@ -8,14 +8,22 @@ import generateTruthyString from '../shared/generateTruthyString'; const head = document.head const body = document.body -function getElement(node) { - if (node.element) return node.element +function getHeadElement(node) { + if (node.element) { + return node.element + } node.element = document.getElementById(node.attributes.id) return node.element } +function removeHeadElement(node) { + const element = getHeadElement(node) + if (element) { + element.remove() + } +} + function updateAttributes(selector, currentAttributes, nextAttributes) { - if (!selector) return const attributeNames = Object.keys({ ...currentAttributes, ...nextAttributes }); for (const name of attributeNames) { if (name === 'html') { @@ -88,17 +96,9 @@ function _rerender(current, next) { return; } - if ((isFalse(current) || isFalse(next)) && current.type != next.type) { - current.element = render(next); - selector.replaceWith(current.element); - if (current.type !== 'head' && next.type !== 'head') { - return - } - } - - if (current.type === 'head' ^ next.type === 'head') { + if ((current.type === 'head' ^ next.type === 'head') && !isFalse(current) && !isFalse(next)) { const nextSelector = render(next); - getElement(current).replaceWith(nextSelector); + current.element.replaceWith(nextSelector); } if (current.type !== 'head' && next.type === 'head') { @@ -108,30 +108,26 @@ function _rerender(current, next) { head.appendChild(nextSelector) } return - } - - if (current.type === 'head' && next.type !== 'head') { - const limit = current.children.length; - for (let i = limit - 1; i > -1; i--) { - getElement(current.children[i])?.remove?.() - } - return - } - - if (next.type === 'head') { + } else if (current.type === 'head' && next.type === 'head') { const limit = Math.max(current.children.length, next.children.length); - for (let i = limit - 1; i > -1; i--) { + for (let i = 0; i < limit; i++) { if (isUndefined(current.children[i]) && !isFalse(next.children[i])) { const nextSelector = render(next.children[i]); head.appendChild(nextSelector) } else if (isUndefined(next.children[i]) && !isFalse(current.children[i])) { - getElement(current.children[i])?.remove?.() + removeHeadElement(current.children[i]) } else if (!isFalse(current.children[i]) && !isFalse(next.children[i])) { if (current.children[i].type === next.children[i].type) { - next.children[i].element = getElement(current.children[i]) - updateAttributes(next.children[i].element, current.children[i].attributes, next.children[i].attributes) + const selector = getHeadElement(current.children[i]) + if (selector) { + next.children[i].element = selector + updateAttributes(selector, current.children[i].attributes, next.children[i].attributes) + } else { + const nextSelector = render(next.children[i]); + head.appendChild(nextSelector) + } } else { - getElement(current.children[i])?.remove?.() + removeHeadElement(current.children[i]) const nextSelector = render(next.children[i]); head.appendChild(nextSelector) } @@ -139,15 +135,22 @@ function _rerender(current, next) { const nextSelector = render(next.children[i]); head.appendChild(nextSelector) } else if (current.children[i].type) { - getElement(current.children[i])?.remove?.() + removeHeadElement(current.children[i]) } } return + } else if (current.type === 'head' && next.type !== 'head') { + const limit = current.children.length; + for (let i = limit - 1; i > -1; i--) { + removeHeadElement(current.children[i]) + } + return } if (current.type !== next.type) { const nextSelector = render(next); - return selector.replaceWith(nextSelector); + selector.replaceWith(nextSelector); + return } if (isText(current) && isText(next)) { diff --git a/tests/src/DynamicHead.njs b/tests/src/DynamicHead.njs index 4c791fdd..df3fb599 100644 --- a/tests/src/DynamicHead.njs +++ b/tests/src/DynamicHead.njs @@ -36,6 +36,9 @@ class DynamicHead extends Nullstack { {this.hydrated &&
} @@ -50,19 +63,6 @@ class StatefulComponent extends Nullstack { {this.visible && } - ) } From 2a46d107a6cf0490e5d7063c986c2167fa7fca82 Mon Sep 17 00:00:00 2001 From: Christian Mortaro Date: Thu, 9 Jun 2022 04:19:27 -0300 Subject: [PATCH 073/105] :sparkles: debounced events --- client/events.js | 71 +++++++++++++++++++------------- tests/src/TwoWayBindings.njs | 13 ++++++ tests/src/TwoWayBindings.test.js | 38 ++++++++++++++++- types/JSX.d.ts | 1 + 4 files changed, 94 insertions(+), 29 deletions(-) diff --git a/client/events.js b/client/events.js index f327a374..f71d20b5 100644 --- a/client/events.js +++ b/client/events.js @@ -3,6 +3,7 @@ import { camelize } from '../shared/string'; 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') { @@ -12,30 +13,21 @@ function executeEvent(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) - 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) return if (subject.href) { if (!event.ctrlKey && !event.shiftKey && !event.altKey && !event.metaKey) { event.preventDefault() @@ -44,13 +36,36 @@ export function generateCallback(selector, name) { } else if (subject.default !== true) { event.preventDefault(); } - if (subject[name] === true) return - if (Array.isArray(subject[name])) { - for (const subcallback of subject[name]) { - executeEvent(subcallback, subject, event, data) + 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]; + } } - } else { - executeEvent(subject[name], subject, event, data) - } + 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] === true) 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/tests/src/TwoWayBindings.njs b/tests/src/TwoWayBindings.njs index e0261b05..40272911 100644 --- a/tests/src/TwoWayBindings.njs +++ b/tests/src/TwoWayBindings.njs @@ -21,6 +21,10 @@ class TwoWayBindings extends Nullstack { external = 'external' + debouncedBind = '69' + debouncedObject = '69' + debouncedEvent = '69' + parse({ event, source: bind, callback }) { const normalized = event.target.value.replace(',', '').padStart(3, '0'); const whole = (parseInt(normalized.slice(0, -2)) || 0).toString(); @@ -50,6 +54,12 @@ class TwoWayBindings extends Nullstack { this.bringsHappiness = bringsHappiness } + debouncedEventHandler({ event }) { + if (event.type === 'click') { + this.debouncedEvent = '6969' + } + } + render({ params }) { return (
@@ -83,6 +93,9 @@ class TwoWayBindings extends Nullstack { + + +
) } diff --git a/tests/src/TwoWayBindings.test.js b/tests/src/TwoWayBindings.test.js index 5def1da0..517074b2 100644 --- a/tests/src/TwoWayBindings.test.js +++ b/tests/src/TwoWayBindings.test.js @@ -143,7 +143,7 @@ describe('TwoWayBindings', () => { const value = await page.$eval('[name="externalComponent"]', (element) => element.value); expect(value).toMatch('external'); }); - + test('bind should rerender in external components', async () => { await page.type('[data-value]', 'new'); await page.waitForSelector('[data-value="external"]') @@ -151,4 +151,40 @@ describe('TwoWayBindings', () => { expect(value).toMatch('newexternal'); }); + test('bind can be debounced', async () => { + await page.type('[data-debounced-bind]', '69'); + await page.waitForTimeout(1000) + const originalValue = await page.$('[data-debounced-bind="69"]'); + await page.waitForTimeout(1500) + const updatedValue = await page.$('[data-debounced-bind="6969"]'); + expect(originalValue && updatedValue).toBeTruthy(); + }); + + test('object events can be debounced', async () => { + await page.click('[data-debounced-object]'); + await page.waitForTimeout(1000) + const originalValue = await page.$('[data-debounced-object="69"]'); + await page.waitForTimeout(1500) + const updatedValue = await page.$('[data-debounced-object="6969"]'); + expect(originalValue && updatedValue).toBeTruthy(); + }); + + test('events can be debounced', async () => { + await page.click('[data-debounced-event]'); + await page.waitForTimeout(1000) + const originalValue = await page.$('[data-debounced-event="69"]'); + await page.waitForTimeout(1500) + const updatedValue = await page.$('[data-debounced-event="6969"]'); + expect(originalValue && updatedValue).toBeTruthy(); + }); + + test('debounced events keep the reference to the original event', async () => { + await page.click('[data-debounced-event]'); + await page.waitForTimeout(1000) + const originalValue = await page.$('[data-debounced-event="69"]'); + await page.waitForTimeout(1500) + const updatedValue = await page.$('[data-debounced-event="6969"]'); + expect(originalValue && updatedValue).toBeTruthy(); + }); + }); \ No newline at end of file diff --git a/types/JSX.d.ts b/types/JSX.d.ts index 584039f5..139d0528 100644 --- a/types/JSX.d.ts +++ b/types/JSX.d.ts @@ -48,6 +48,7 @@ export interface Attributes { html?: string | undefined; source?: object | undefined; bind?: any | undefined; + debounce?: number | undefined; ref?: any | undefined; data?: object | undefined; "data-"?: any; From d737897c6dc6933b970130c386ca81fd3ed716bf Mon Sep 17 00:00:00 2001 From: Christian Mortaro Date: Sat, 11 Jun 2022 01:36:03 -0300 Subject: [PATCH 074/105] :sparkles: remove debounce from dom --- client/render.js | 1 + client/rerender.js | 1 + server/renderAttributes.js | 1 + tests/src/TwoWayBindings.njs | 3 +++ tests/src/TwoWayBindings.test.js | 18 ++++++++++++++++++ 5 files changed, 24 insertions(+) diff --git a/client/render.js b/client/render.js index e4c0bc21..cf17b1b4 100644 --- a/client/render.js +++ b/client/render.js @@ -27,6 +27,7 @@ export default function render(node, options) { ref(node, node.element) for (let name in node.attributes) { + if (name === 'debounce') continue if (name === 'html') { node.element.innerHTML = node.attributes[name]; node.head || anchorableElement(node.element); diff --git a/client/rerender.js b/client/rerender.js index 9b4f645c..9488e596 100644 --- a/client/rerender.js +++ b/client/rerender.js @@ -8,6 +8,7 @@ import generateTruthyString from '../shared/generateTruthyString'; function updateAttributes(selector, currentAttributes, nextAttributes) { const attributeNames = Object.keys({ ...currentAttributes, ...nextAttributes }); for (const name of attributeNames) { + if (name === 'debounce') continue if (name === 'html') { if (nextAttributes[name] !== currentAttributes[name]) { selector.innerHTML = nextAttributes[name]; diff --git a/server/renderAttributes.js b/server/renderAttributes.js index b678d3fa..01e5a37a 100644 --- a/server/renderAttributes.js +++ b/server/renderAttributes.js @@ -3,6 +3,7 @@ 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])) { diff --git a/tests/src/TwoWayBindings.njs b/tests/src/TwoWayBindings.njs index 40272911..161e447b 100644 --- a/tests/src/TwoWayBindings.njs +++ b/tests/src/TwoWayBindings.njs @@ -24,6 +24,7 @@ class TwoWayBindings extends Nullstack { debouncedBind = '69' debouncedObject = '69' debouncedEvent = '69' + debounceTime = 1000 parse({ event, source: bind, callback }) { const normalized = event.target.value.replace(',', '').padStart(3, '0'); @@ -96,6 +97,8 @@ class TwoWayBindings extends Nullstack { + {this.hydrated && } +
) } diff --git a/tests/src/TwoWayBindings.test.js b/tests/src/TwoWayBindings.test.js index 517074b2..ca044e01 100644 --- a/tests/src/TwoWayBindings.test.js +++ b/tests/src/TwoWayBindings.test.js @@ -187,4 +187,22 @@ describe('TwoWayBindings', () => { expect(originalValue && updatedValue).toBeTruthy(); }); + test('debounce attribute should not be present in dom during prerender', async () => { + const element = await page.$('[data-debounced-event]:not([debounce])'); + expect(element).toBeTruthy(); + }); + + test('debounce attribute should not be present in dom during render', async () => { + await page.waitForSelector('[data-debounced-hydrated]') + const element = await page.$('[data-debounced-hydrated]:not([debounce])'); + expect(element).toBeTruthy(); + }); + + test('debounce attribute should not be present in dom during rerender', async () => { + await page.click('[data-debounced-rerender]:not([debounce])'); + await page.waitForSelector('[data-debounced-rerender="2000"]:not([debounce])') + const element = await page.$('[data-debounced-rerender="2000"]:not([debounce])'); + expect(element).toBeTruthy(); + }); + }); \ No newline at end of file From 92e69c1f8c8f3baecdfaa2a11b7c230680cc4629 Mon Sep 17 00:00:00 2001 From: Christian Mortaro Date: Sat, 11 Jun 2022 20:11:08 -0300 Subject: [PATCH 075/105] :recycle: more consistent underscored attributes --- client/instanceProxyHandler.js | 7 +++++-- server/index.js | 7 ++++--- server/instanceProxyHandler.js | 5 ++++- tests/src/UnderscoredAttributes.njs | 21 +++++++++++++++++---- tests/src/UnderscoredAttributes.test.js | 13 +++++++++++++ 5 files changed, 43 insertions(+), 10 deletions(-) diff --git a/client/instanceProxyHandler.js b/client/instanceProxyHandler.js index 89cdc858..230a6cd8 100644 --- a/client/instanceProxyHandler.js +++ b/client/instanceProxyHandler.js @@ -8,11 +8,14 @@ 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 (target[name]?.name?.startsWith?.('_') || name.startsWith('_')) { + return target[name].bind(proxy) + } const { [name]: named } = { [name]: (args) => { const context = generateContext({ ...target._attributes, ...args }); - const proxy = instanceProxies.get(target) return target[name].call(proxy, context); } } diff --git a/server/index.js b/server/index.js index 9da83611..016c10e3 100644 --- a/server/index.js +++ b/server/index.js @@ -68,9 +68,10 @@ class Nullstack { if (name === 'hydrated') continue if (name === 'terminated') continue if (name === 'key') continue - if (typeof this[name] !== 'function' && !name.startsWith('_') && name !== 'attributes') { - serialized[name] = this[name]; - } + 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 0b21a4d0..aeba0670 100644 --- a/server/instanceProxyHandler.js +++ b/server/instanceProxyHandler.js @@ -1,6 +1,9 @@ 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 (target[name]?.name?.startsWith?.('_') || name?.startsWith?.('_')) { + return target[name].bind(target) + } return (args) => { const context = target._scope.generateContext({ ...target._attributes, ...args }); return target[name](context); diff --git a/tests/src/UnderscoredAttributes.njs b/tests/src/UnderscoredAttributes.njs index 2b910007..7ea69ff3 100644 --- a/tests/src/UnderscoredAttributes.njs +++ b/tests/src/UnderscoredAttributes.njs @@ -11,8 +11,8 @@ const underscoredObject = { } function _underscored(value) { - this.e = value - } + this.e = value +} class UnderscoredAttributes extends Nullstack { @@ -21,6 +21,11 @@ class UnderscoredAttributes extends Nullstack { c = 0 d = 0 e = 0 + f = 0 + + prepare() { + this._g = 1 + } _underscoredMethod(value) { this.a = value @@ -32,6 +37,10 @@ class UnderscoredAttributes extends Nullstack { notUnderscored = _underscored + _underscoredEvent() { + this.f = 1 + } + hydrate() { this._underscoredMethod(1) this._underscoredAttributeFunction(1) @@ -42,10 +51,14 @@ class UnderscoredAttributes extends Nullstack { this.notUnderscored(1) } + setEventListener({ element }) { + element.addEventListener('click', this._underscoredEvent) + } + render() { return ( -
- UnderscoredAttributes +
+
) } diff --git a/tests/src/UnderscoredAttributes.test.js b/tests/src/UnderscoredAttributes.test.js index 52ad1f92..3a7c9020 100644 --- a/tests/src/UnderscoredAttributes.test.js +++ b/tests/src/UnderscoredAttributes.test.js @@ -36,4 +36,17 @@ describe('UnderscoredAttributes', () => { expect(element).toBeTruthy(); }); + test('this is bound to the instance on underscored functions even on events', async () => { + await page.waitForSelector('[data-hydrated]') + await page.click('[data-hydrated]') + await page.waitForSelector('[data-f="1"]') + const element = await page.$('[data-f="1"]'); + expect(element).toBeTruthy(); + }); + + test('underscored variables are serialized for hydration', async () => { + const element = await page.$('[data-g="1"]'); + expect(element).toBeTruthy(); + }); + }); \ No newline at end of file From edfb535fc056453a535735157154d3478a96417b Mon Sep 17 00:00:00 2001 From: Christian Mortaro Date: Tue, 14 Jun 2022 23:57:42 -0300 Subject: [PATCH 076/105] :sparkles: bind noop and inner references --- client/events.js | 3 +- loaders/register-inner-components.js | 32 ++++++++++--------- plugins/anchorable.js | 4 ++- plugins/bindable.js | 8 +++-- shared/noop.js | 1 + tests/src/Element.njs | 29 +++++++++++++++-- tests/src/Element.test.js | 16 ++++++++++ tests/src/RenderableComponent.njs | 11 +++++-- tests/src/RenderableComponent.test.js | 5 +++ tests/src/TwoWayBindings.test.js | 6 ++++ tests/src/TwoWayBindingsExternalComponent.njs | 4 +-- 11 files changed, 93 insertions(+), 26 deletions(-) create mode 100644 shared/noop.js diff --git a/client/events.js b/client/events.js index f71d20b5..7ac83483 100644 --- a/client/events.js +++ b/client/events.js @@ -1,5 +1,6 @@ import router from './router' import { camelize } from '../shared/string'; +import noop from '../shared/noop' export const eventCallbacks = new WeakMap() export const eventSubjects = new WeakMap() @@ -58,7 +59,7 @@ export function generateCallback(selector, name) { object[property] = event.target[valueName]; } } - if (subject[name] === true) return + if (subject[name] === noop) return if (Array.isArray(subject[name])) { for (const subcallback of subject[name]) { executeEvent(subcallback, subject, event, data) 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/plugins/anchorable.js b/plugins/anchorable.js index f2b9351f..8769b4f9 100644 --- a/plugins/anchorable.js +++ b/plugins/anchorable.js @@ -1,3 +1,5 @@ +import noop from '../shared/noop' + function match(node) { return ( node && @@ -10,7 +12,7 @@ function match(node) { function transform({ node }) { if (!match(node)) return - node.attributes.onclick ??= true + node.attributes.onclick ??= noop } export default { transform, client: true } diff --git a/plugins/bindable.js b/plugins/bindable.js index 7ab9bc4e..219f9ce5 100644 --- a/plugins/bindable.js +++ b/plugins/bindable.js @@ -1,3 +1,5 @@ +import noop from '../shared/noop' + function match(node) { return node?.attributes?.bind !== undefined } @@ -16,11 +18,11 @@ function transform({ node, environment }) { node.attributes.name = node.attributes.name || node.attributes.bind; if (environment.client) { if (node.attributes.type === 'checkbox' || node.attributes.type === 'radio') { - node.attributes.onclick ??= true; + node.attributes.onclick ??= noop; } else if (node.type !== 'input' && node.type !== 'textarea') { - node.attributes.onchange ??= true; + node.attributes.onchange ??= noop; } else { - node.attributes.oninput ??= true; + node.attributes.oninput ??= noop; } } } 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/tests/src/Element.njs b/tests/src/Element.njs index 923e9c97..e9755005 100644 --- a/tests/src/Element.njs +++ b/tests/src/Element.njs @@ -1,14 +1,39 @@ import Nullstack from 'nullstack'; +class ClassElement extends Nullstack { + + render({ prop }) { + return ( +
+ ) + } + +} + +function FunctionalElement({ prop }) { + return ( +
+ ) +} + class Element extends Nullstack { - + + renderInnerElement({ prop }) { + return ( +
+ ) + } + render() { return ( - <> + <> b abbr + + + ) } diff --git a/tests/src/Element.test.js b/tests/src/Element.test.js index 6947b7f0..d5606a4a 100644 --- a/tests/src/Element.test.js +++ b/tests/src/Element.test.js @@ -19,4 +19,20 @@ describe('FullStackLifecycle', () => { expect(element).toBeFalsy(); }); + test('elements can be inner components and receive props', async () => { + const element = await page.$('[data-inner-component]'); + expect(element).toBeTruthy(); + }); + + test('elements can be class components and receive props', async () => { + const element = await page.$('[data-class-component]'); + expect(element).toBeTruthy(); + }); + + test('elements can be functional components and receive props', async () => { + const element = await page.$('[data-functional-component]'); + expect(element).toBeTruthy(); + }); + + }); \ No newline at end of file diff --git a/tests/src/RenderableComponent.njs b/tests/src/RenderableComponent.njs index b3fbf2c4..ffdbf145 100644 --- a/tests/src/RenderableComponent.njs +++ b/tests/src/RenderableComponent.njs @@ -6,16 +6,23 @@ class RenderableComponent extends Nullstack { return
} - renderInnerComponent({ children }) { + renderInnerComponent({ children, reference: Reference }) { return (

Inner Component

+ {children}
) } + renderInnerReference({ prop }) { + return ( +
+ ) + } + renderFalsy() { return false; } @@ -37,7 +44,7 @@ class RenderableComponent extends Nullstack { element tag - + children
    diff --git a/tests/src/RenderableComponent.test.js b/tests/src/RenderableComponent.test.js index f05f71ad..307ffaf4 100644 --- a/tests/src/RenderableComponent.test.js +++ b/tests/src/RenderableComponent.test.js @@ -84,6 +84,11 @@ describe('RenderableComponent', () => { expect(element).toBeFalsy(); }); + test('inner components can be referenced and receive props', async () => { + const element = await page.$('[data-reference]'); + expect(element).toBeTruthy(); + }); + }); describe('RenderableComponent ?condition=true', () => { diff --git a/tests/src/TwoWayBindings.test.js b/tests/src/TwoWayBindings.test.js index ca044e01..a405aad0 100644 --- a/tests/src/TwoWayBindings.test.js +++ b/tests/src/TwoWayBindings.test.js @@ -205,4 +205,10 @@ describe('TwoWayBindings', () => { expect(element).toBeTruthy(); }); + test('custom bindable components receive a no op function if no onchange is passed to them', async () => { + await page.waitForSelector('[data-onchange="noop"]') + const element = await page.$('[data-onchange="noop"]'); + expect(element).toBeTruthy(); + }); + }); \ No newline at end of file diff --git a/tests/src/TwoWayBindingsExternalComponent.njs b/tests/src/TwoWayBindingsExternalComponent.njs index b61ec26e..2f4d85fb 100644 --- a/tests/src/TwoWayBindingsExternalComponent.njs +++ b/tests/src/TwoWayBindingsExternalComponent.njs @@ -2,10 +2,10 @@ import Nullstack from 'nullstack'; class TwoWayBindingsExternalComponent extends Nullstack { - render({ bind }) { + render({ bind, onchange }) { return (
    - +
    ) } From 44595918fec20f38c720b450ba9fdf0e263f2c4e Mon Sep 17 00:00:00 2001 From: Christian Mortaro Date: Sat, 18 Jun 2022 21:04:22 -0300 Subject: [PATCH 077/105] :sparkles: improved refs --- client/hydrate.js | 4 ++-- client/ref.js | 23 +++++++++++++++++++---- client/render.js | 4 ++-- client/rerender.js | 5 ++++- 4 files changed, 27 insertions(+), 9 deletions(-) diff --git a/client/hydrate.js b/client/hydrate.js index 9271111d..e07821ac 100644 --- a/client/hydrate.js +++ b/client/hydrate.js @@ -1,4 +1,4 @@ -import ref from './ref'; +import { ref } from './ref'; import { isFalse } from '../shared/nodes'; import { anchorableElement } from './anchorableNode'; import client from './client'; @@ -10,7 +10,7 @@ function hydrateBody(selector, node) { anchorableElement(selector); } node.element = selector - ref(node, 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('')); diff --git a/client/ref.js b/client/ref.js index 859227e8..a9d2b829 100644 --- a/client/ref.js +++ b/client/ref.js @@ -1,7 +1,8 @@ -export default function ref(node, element) { - if (!node.attributes?.ref) return - const object = node.attributes.ref.object - const property = node.attributes.ref.property +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]({ element }) @@ -9,4 +10,18 @@ export default function ref(node, element) { } 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 cf17b1b4..6ca7e134 100644 --- a/client/render.js +++ b/client/render.js @@ -1,7 +1,7 @@ import { isFalse, isText } from '../shared/nodes'; import { anchorableElement } from './anchorableNode'; import { eventCallbacks, eventSubjects, generateCallback } from './events' -import ref from './ref' +import { ref } from './ref' import generateTruthyString from '../shared/generateTruthyString'; export default function render(node, options) { @@ -24,7 +24,7 @@ export default function render(node, options) { node.element = document.createElement(node.type); } - ref(node, node.element) + ref(node.attributes, node.element) for (let name in node.attributes) { if (name === 'debounce') continue diff --git a/client/rerender.js b/client/rerender.js index 9488e596..95723d06 100644 --- a/client/rerender.js +++ b/client/rerender.js @@ -4,12 +4,15 @@ import client from './client'; import render from './render'; 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 === 'html') { + if (name === 'ref') { + reref(nextAttributes, selector) + } else if (name === 'html') { if (nextAttributes[name] !== currentAttributes[name]) { selector.innerHTML = nextAttributes[name]; anchorableElement(selector); From edb4904577b1abf67b53f021721ef207550aafc0 Mon Sep 17 00:00:00 2001 From: Christian Mortaro Date: Sat, 18 Jun 2022 21:30:46 -0300 Subject: [PATCH 078/105] :white_check_mark: instance ref tests --- tests/src/Application.njs | 5 +++-- tests/src/Refs.njs | 13 +++++++++---- tests/src/Refs.test.js | 21 ++++++++++++++++++--- 3 files changed, 30 insertions(+), 9 deletions(-) diff --git a/tests/src/Application.njs b/tests/src/Application.njs index 8aed7f33..af60504b 100644 --- a/tests/src/Application.njs +++ b/tests/src/Application.njs @@ -62,9 +62,10 @@ class Application extends Nullstack { prepare(context) { context.string = 'nullstack'; + context.refInstanceCount = 0 } - render({ project, page, environment }) { + render({ project, page, environment, refInstanceCount }) { return (

    {project.name}

    @@ -125,7 +126,7 @@ class Application extends Nullstack { - + diff --git a/tests/src/Refs.njs b/tests/src/Refs.njs index f8788d55..ea08a5af 100644 --- a/tests/src/Refs.njs +++ b/tests/src/Refs.njs @@ -8,9 +8,9 @@ class Refs extends Nullstack { this.id = this._element.id } - setRef({ element }) { + setRef({ element, refInstanceCount }) { this._function = element - this.isOnDOM = element.offsetHeight > 0 + this.isOnDOM = element.offsetHeight > 0 && refInstanceCount } renderBubble({ ref }) { @@ -19,14 +19,19 @@ class Refs extends Nullstack { ) } - render() { + changeInstance(context) { + context.refInstanceCount++ + } + + render({ refInstanceCount }) { return ( -
    +
    span +
    ) } diff --git a/tests/src/Refs.test.js b/tests/src/Refs.test.js index dcc27181..71a38e1a 100644 --- a/tests/src/Refs.test.js +++ b/tests/src/Refs.test.js @@ -1,6 +1,6 @@ describe('Refs', () => { - beforeAll(async () => { + beforeEach(async () => { await page.goto('http://localhost:6969/'); await page.click('[href="/refs"]') }); @@ -36,8 +36,8 @@ describe('Refs', () => { }); test('refs functions only run after the element is appended do DOM', async () => { - await page.waitForSelector('[data-dom]'); - const element = await page.$('[data-dom]'); + await page.waitForSelector('[data-dom="0"]'); + const element = await page.$('[data-dom="0"]'); expect(element).toBeTruthy(); }); @@ -47,5 +47,20 @@ describe('Refs', () => { expect(element).toBeTruthy(); }); + test('refs functions run when the ref object changes', async () => { + await page.waitForSelector('[data-dom="0"]'); + await page.click("button") + await page.waitForSelector('[data-dom="1"]'); + const element = await page.$('[data-dom="1"]'); + expect(element).toBeTruthy(); + }); + + test('refs are reassigned when the ref object changes ', async () => { + await page.waitForSelector('[data-dom="0"]'); + await page.click("button") + await page.waitForSelector('[data-id="hydrate-element"][data-instance="1"]'); + const element = await page.$('[data-id="hydrate-element"][data-instance="1"]'); + expect(element).toBeTruthy(); + }); }); \ No newline at end of file From 5e3b80eef2e1f280f3f0aa0809bb76c4991e890e Mon Sep 17 00:00:00 2001 From: Christian Mortaro Date: Sun, 19 Jun 2022 01:45:21 -0300 Subject: [PATCH 079/105] :zap: faster simpler proxies --- client/instanceProxyHandler.js | 4 ++-- client/objectProxyHandler.js | 25 +++++++++++-------------- server/instanceProxyHandler.js | 2 +- tests/src/NestedProxy.njs | 21 ++++++++++++++++++++- tests/src/NestedProxy.test.js | 20 ++++++++++++++++---- tests/src/UnderscoredAttributes.njs | 6 +++--- tests/src/UnderscoredAttributes.test.js | 2 +- 7 files changed, 54 insertions(+), 26 deletions(-) diff --git a/client/instanceProxyHandler.js b/client/instanceProxyHandler.js index 230a6cd8..fdf83cbd 100644 --- a/client/instanceProxyHandler.js +++ b/client/instanceProxyHandler.js @@ -10,7 +10,7 @@ const instanceProxyHandler = { if (target.constructor[name]?.name === '_invoke') return target.constructor[name].bind(target.constructor) if (typeof target[name] === 'function' && name !== 'constructor') { const proxy = instanceProxies.get(target) - if (target[name]?.name?.startsWith?.('_') || name.startsWith('_')) { + if (name.startsWith('_')) { return target[name].bind(proxy) } const { [name]: named } = { @@ -24,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/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/server/instanceProxyHandler.js b/server/instanceProxyHandler.js index aeba0670..3daf4e60 100644 --- a/server/instanceProxyHandler.js +++ b/server/instanceProxyHandler.js @@ -1,7 +1,7 @@ const instanceProxyHandler = { get(target, name) { if (typeof target[name] === 'function' && name !== 'constructor') { - if (target[name]?.name?.startsWith?.('_') || name?.startsWith?.('_')) { + if (name.startsWith('_')) { return target[name].bind(target) } return (args) => { diff --git a/tests/src/NestedProxy.njs b/tests/src/NestedProxy.njs index 047538a9..7af544e3 100644 --- a/tests/src/NestedProxy.njs +++ b/tests/src/NestedProxy.njs @@ -1,5 +1,15 @@ import Nullstack from 'nullstack'; +class ShouldNotProxy { + + something = false + + setSomething(value) { + this.something = value + } + +} + class NestedProxy extends Nullstack { array = [ @@ -19,7 +29,14 @@ class NestedProxy extends Nullstack { } } - render({ array, object }) { + hydrate(context) { + this.shouldNotProxy = new ShouldNotProxy() + this.shouldNotProxy.setSomething(true) + context.shouldNotProxy = new ShouldNotProxy() + context.shouldNotProxy.setSomething(true) + } + + render({ array, object, shouldNotProxy }) { if (!this.hydrated) return false return (
    ) } diff --git a/tests/src/NestedProxy.test.js b/tests/src/NestedProxy.test.js index c47ddaaa..1f7fc69f 100644 --- a/tests/src/NestedProxy.test.js +++ b/tests/src/NestedProxy.test.js @@ -34,34 +34,46 @@ describe('ParentComponent', () => { expect(element).toBeTruthy(); }); + test('non direct object instances nested to instance do not become proxies', async () => { + await page.waitForSelector('[data-should-not-proxy]'); + const element = await page.$('[data-should-not-proxy]'); + expect(element).toBeTruthy(); + }); + test('context arrays become proxies', async () => { await page.waitForSelector('[data-context-array]'); const element = await page.$('[data-context-array]'); expect(element).toBeTruthy(); }); - + test('objects nested to context arrays become proxies', async () => { await page.waitForSelector('[data-context-array-zero]'); const element = await page.$('[data-context-array-zero]'); expect(element).toBeTruthy(); }); - + test('objects nested to context arrays become proxies', async () => { await page.waitForSelector('[data-context-array-zero-object]'); const element = await page.$('[data-context-array-zero-object]'); expect(element).toBeTruthy(); }); - + test('context objects become proxies', async () => { await page.waitForSelector('[data-context-object]'); const element = await page.$('[data-context-object]'); expect(element).toBeTruthy(); }); - + test('arrays nested to context objects become proxies', async () => { await page.waitForSelector('[data-context-object-array]'); const element = await page.$('[data-context-object-array]'); expect(element).toBeTruthy(); }); + test('non direct object instances nested to context do not become proxies', async () => { + await page.waitForSelector('[data-context-should-not-proxy]'); + const element = await page.$('[data-context-should-not-proxy]'); + expect(element).toBeTruthy(); + }); + }); \ No newline at end of file diff --git a/tests/src/UnderscoredAttributes.njs b/tests/src/UnderscoredAttributes.njs index 7ea69ff3..70e1ec5a 100644 --- a/tests/src/UnderscoredAttributes.njs +++ b/tests/src/UnderscoredAttributes.njs @@ -10,8 +10,8 @@ const underscoredObject = { } } -function _underscored(value) { - this.e = value +function _underscored({ string, value }) { + this.e = string === 'nullstack' && value } class UnderscoredAttributes extends Nullstack { @@ -48,7 +48,7 @@ class UnderscoredAttributes extends Nullstack { this._underscoredAfterConstructor(1) this._underscoredObject = underscoredObject this._underscoredObject.withoutUnderscore(1) - this.notUnderscored(1) + this.notUnderscored({ value: 1 }) } setEventListener({ element }) { diff --git a/tests/src/UnderscoredAttributes.test.js b/tests/src/UnderscoredAttributes.test.js index 3a7c9020..aec0bd21 100644 --- a/tests/src/UnderscoredAttributes.test.js +++ b/tests/src/UnderscoredAttributes.test.js @@ -31,7 +31,7 @@ describe('UnderscoredAttributes', () => { expect(element).toBeTruthy(); }); - test('keys assigned with a function that name is underscored do not receive the context as argument', async () => { + test('keys assigned with a function that name is underscored receive the context as argument', async () => { const element = await page.$('[data-e="1"]'); expect(element).toBeTruthy(); }); From f3efaaa51917e5b821add1b093189a4d6633947e Mon Sep 17 00:00:00 2001 From: Jonathan Bertoldi Date: Tue, 21 Jun 2022 15:42:01 -0300 Subject: [PATCH 080/105] Fix ref undefined --- client/rerender.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/rerender.js b/client/rerender.js index 95723d06..e55215e0 100644 --- a/client/rerender.js +++ b/client/rerender.js @@ -10,7 +10,7 @@ function updateAttributes(selector, currentAttributes, nextAttributes) { const attributeNames = Object.keys({ ...currentAttributes, ...nextAttributes }); for (const name of attributeNames) { if (name === 'debounce') continue - if (name === 'ref') { + if (name === 'ref' && nextAttributes === undefined) { reref(nextAttributes, selector) } else if (name === 'html') { if (nextAttributes[name] !== currentAttributes[name]) { @@ -154,4 +154,4 @@ export default function rerender() { client.nextBody = {} client.currentHead = client.nextHead client.nextHead = [] -} \ No newline at end of file +} From c05952d999aa6e9f164e03f0fb15d84efcc86b37 Mon Sep 17 00:00:00 2001 From: Jonathan Bertoldi Date: Tue, 21 Jun 2022 15:48:57 -0300 Subject: [PATCH 081/105] Fix mistaken Comparison Operators --- client/rerender.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/rerender.js b/client/rerender.js index e55215e0..247681d4 100644 --- a/client/rerender.js +++ b/client/rerender.js @@ -10,7 +10,7 @@ function updateAttributes(selector, currentAttributes, nextAttributes) { const attributeNames = Object.keys({ ...currentAttributes, ...nextAttributes }); for (const name of attributeNames) { if (name === 'debounce') continue - if (name === 'ref' && nextAttributes === undefined) { + if (name === 'ref' && nextAttributes !== undefined) { reref(nextAttributes, selector) } else if (name === 'html') { if (nextAttributes[name] !== currentAttributes[name]) { From c0ecf8c5a3df45e9e96597b065d4caaff652c50b Mon Sep 17 00:00:00 2001 From: Jonathan Bertoldi Date: Tue, 21 Jun 2022 16:24:00 -0300 Subject: [PATCH 082/105] better approach --- client/rerender.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/rerender.js b/client/rerender.js index 247681d4..f67a285b 100644 --- a/client/rerender.js +++ b/client/rerender.js @@ -10,7 +10,7 @@ function updateAttributes(selector, currentAttributes, nextAttributes) { const attributeNames = Object.keys({ ...currentAttributes, ...nextAttributes }); for (const name of attributeNames) { if (name === 'debounce') continue - if (name === 'ref' && nextAttributes !== undefined) { + if (name === 'ref' && nextAttributes?.ref?.property) { reref(nextAttributes, selector) } else if (name === 'html') { if (nextAttributes[name] !== currentAttributes[name]) { From a8c7a04398f70fbf228ce614facb01d334337e9d Mon Sep 17 00:00:00 2001 From: Jonathan Bertoldi Date: Fri, 8 Jul 2022 08:26:15 -0300 Subject: [PATCH 083/105] Fix bindable Fix bindable when object can be possible undefined, for instance: `bind={user.profile?.age`. Turns the responsibility to the user being easier to identify the error. --- plugins/bindable.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/bindable.js b/plugins/bindable.js index 219f9ce5..e0dc0827 100644 --- a/plugins/bindable.js +++ b/plugins/bindable.js @@ -6,7 +6,7 @@ function match(node) { function transform({ node, environment }) { if (!match(node)) return; - const object = node.attributes.bind.object; + const object = node.attributes.bind.object ?? {}; const property = node.attributes.bind.property; if (node.type === 'textarea') { node.children = [object[property]]; @@ -27,4 +27,4 @@ function transform({ node, environment }) { } } -export default { transform, client: true, server: true } \ No newline at end of file +export default { transform, client: true, server: true } From 065bb0992bdc4e890445a6cc03e0138f7cb456b0 Mon Sep 17 00:00:00 2001 From: Christian Mortaro Date: Sun, 24 Jul 2022 15:24:53 -0300 Subject: [PATCH 084/105] :sparkles: static server functions with context --- loaders/register-static-from-server.js | 4 +--- server/index.js | 31 +++++++++++++++++++++----- server/invoke.js | 10 --------- server/reqres.js | 2 ++ server/server.js | 3 +++ tests/package.json | 2 +- tests/src/ChildComponent.njs | 8 +++++++ tests/src/ChildComponent.test.js | 28 +++++++++++++++++++++-- tests/src/ParentComponent.njs | 4 ++-- tests/src/ServerFunctions.njs | 8 +++++++ tests/src/ServerFunctions.test.js | 12 ++++++++++ 11 files changed, 89 insertions(+), 23 deletions(-) delete mode 100644 server/invoke.js create mode 100644 server/reqres.js diff --git a/loaders/register-static-from-server.js b/loaders/register-static-from-server.js index d3d932dd..e9be0720 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,6 @@ module.exports = function (source) { } output += `\nNullstack.registry["${hash}"] = ${klassName};` output += `\nNullstack.registry["${legacyHash}"] = ${klassName};` + output += `\n${klassName}.bindStaticFunctions(${klassName});` return output; } \ No newline at end of file diff --git a/server/index.js b/server/index.js index 016c10e3..6d4e7674 100644 --- a/server/index.js +++ b/server/index.js @@ -8,13 +8,14 @@ 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,10 +32,32 @@ class Nullstack { static registry = registry; static element = element; - static invoke = invoke; static fragment = fragment; static use = useServerPlugins; + static bindStaticFunctions(klass) { + let parent = klass + while (parent.name !== 'Nullstack') { + const props = Object.getOwnPropertyNames(parent) + for (const prop of props) { + if (typeof klass[prop] === 'function') { + const propName = `__NULLSTACK_${prop}` + if (!klass[propName]) { + klass[propName] = klass[prop] + } + async function _invoke(params = {}) { + const { request, response } = reqres + const context = generateContext({ request, response, ...params }); + return await 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) { generator.starter = () => element(Starter); @@ -49,9 +72,7 @@ class Nullstack { 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) { 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/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 3f158474..55f23641 100644 --- a/server/server.js +++ b/server/server.js @@ -16,6 +16,7 @@ import registry from './registry'; import generateRobots from './robots'; import template from './template'; import { generateServiceWorker } from './worker'; +import reqres from './reqres' if (!global.fetch) { global.fetch = fetch; @@ -197,6 +198,8 @@ server.start = function () { 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; diff --git a/tests/package.json b/tests/package.json index e6cbb192..35868dde 100644 --- a/tests/package.json +++ b/tests/package.json @@ -13,7 +13,7 @@ "purgecss-webpack-plugin": "^4.1.3" }, "scripts": { - "start": "npx nullstack start --input=./tests --port=6969 --env=test --mode=spa --hot", + "start": "npx nullstack start --input=./tests --port=6969 --env=test", "build": "npx nullstack build --input=./tests --env=test", "test": "npm run build && jest", "script": "node src/scripts/run.js" diff --git a/tests/src/ChildComponent.njs b/tests/src/ChildComponent.njs index 04078cee..8c212b39 100644 --- a/tests/src/ChildComponent.njs +++ b/tests/src/ChildComponent.njs @@ -9,11 +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(); + this.staticHydratedChildThis = await ChildComponent.getChildThis(); + this.staticHydratedParentThis = await ParentComponent.getParentThis(); this.bunda = 'true' } @@ -25,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/ParentComponent.njs b/tests/src/ParentComponent.njs index 94b6b718..c83a1818 100644 --- a/tests/src/ParentComponent.njs +++ b/tests/src/ParentComponent.njs @@ -1,8 +1,8 @@ import Nullstack from 'nullstack'; class ParentComponent extends Nullstack { - - static async getParentThis() { + + static async getParentThis(context) { return this.name; } diff --git a/tests/src/ServerFunctions.njs b/tests/src/ServerFunctions.njs index c9d391f0..02e84063 100644 --- a/tests/src/ServerFunctions.njs +++ b/tests/src/ServerFunctions.njs @@ -63,10 +63,15 @@ class ServerFunctions extends Nullstack { return true } + static async getRequestUrl({ request }) { + return request.originalUrl.startsWith('/') + } + async initiate() { this.statement = await this.useNodeFileSystem(); this.response = await this.useFetchInNode(); this.doublePlusOneServer = await ServerFunctions.getDoublePlusOne({ number: 34 }) + this.originalUrl = await ServerFunctions.getRequestUrl() } async hydrate() { @@ -74,6 +79,7 @@ class ServerFunctions extends Nullstack { this.clientOnly = clientOnly(); this.doublePlusOneClient = await ServerFunctions.getDoublePlusOne({ number: 34 }) this.acceptsSpecialCharacters = await this.getEncodedString({ string: decodedString }) + this.hydratedOriginalUrl = await ServerFunctions.getRequestUrl() } render() { @@ -91,6 +97,8 @@ class ServerFunctions extends Nullstack {
    +
    +
    ) } diff --git a/tests/src/ServerFunctions.test.js b/tests/src/ServerFunctions.test.js index 14608f57..85e0a38f 100644 --- a/tests/src/ServerFunctions.test.js +++ b/tests/src/ServerFunctions.test.js @@ -66,4 +66,16 @@ describe('ServerFunctions', () => { expect(element).toBeTruthy(); }); + test('static server functions receive the context on spa', async () => { + await page.waitForSelector('[data-hydrated-original-url]'); + const element = await page.$('[data-hydrated-original-url]'); + expect(element).toBeTruthy(); + }); + + test('static server functions receive the context on ssr', async () => { + await page.waitForSelector('[data-original-url]'); + const element = await page.$('[data-original-url]'); + expect(element).toBeTruthy(); + }); + }); \ No newline at end of file From c75907681481a612a8959e3f1aa36f8b96930f34 Mon Sep 17 00:00:00 2001 From: Christian Mortaro Date: Sun, 24 Jul 2022 16:26:54 -0300 Subject: [PATCH 085/105] :wrench: webpack dependency --- tests/package.json | 2 +- webpack.config.js | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/package.json b/tests/package.json index 35868dde..e6cbb192 100644 --- a/tests/package.json +++ b/tests/package.json @@ -13,7 +13,7 @@ "purgecss-webpack-plugin": "^4.1.3" }, "scripts": { - "start": "npx nullstack start --input=./tests --port=6969 --env=test", + "start": "npx nullstack start --input=./tests --port=6969 --env=test --mode=spa --hot", "build": "npx nullstack build --input=./tests --env=test", "test": "npm run build && jest", "script": "node src/scripts/run.js" diff --git a/webpack.config.js b/webpack.config.js index 7114e647..925396a9 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -117,6 +117,7 @@ function server(env, argv) { const minimize = !isDev; const plugins = [] return { + name: 'server', mode: argv.environment, entry: './server.js', output: { @@ -240,6 +241,8 @@ function client(env, argv) { plugins.push(new HotModuleReplacementPlugin()) } return { + name: 'client', + dependencies: ['server'], infrastructureLogging: { level: 'none', }, From 2d26346ea174251ad87201149fb1990a7a67ca61 Mon Sep 17 00:00:00 2001 From: Christian Mortaro Date: Sun, 24 Jul 2022 17:18:59 -0300 Subject: [PATCH 086/105] :white_check_mark: tests for nested undeclared bind --- tests/src/TwoWayBindings.njs | 1 + tests/src/TwoWayBindings.test.js | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/tests/src/TwoWayBindings.njs b/tests/src/TwoWayBindings.njs index 161e447b..b95e60f0 100644 --- a/tests/src/TwoWayBindings.njs +++ b/tests/src/TwoWayBindings.njs @@ -84,6 +84,7 @@ class TwoWayBindings extends Nullstack { + diff --git a/tests/src/TwoWayBindings.test.js b/tests/src/TwoWayBindings.test.js index a405aad0..d46091d8 100644 --- a/tests/src/TwoWayBindings.test.js +++ b/tests/src/TwoWayBindings.test.js @@ -211,4 +211,9 @@ describe('TwoWayBindings', () => { expect(element).toBeTruthy(); }); + test('binding to nested undefined sets the value to an empty string', async () => { + const value = await page.$eval('[data-undeclared-nested]', (element) => element.value); + expect(value).toMatch(''); + }); + }); \ No newline at end of file From 9e88bcb9226a596cafec9061c9d56df9fea88697 Mon Sep 17 00:00:00 2001 From: Christian Mortaro Date: Sat, 30 Jul 2022 13:40:38 -0300 Subject: [PATCH 087/105] :construction: improved webpack servers --- client/index.js | 15 ++++-- package.json | 1 + scripts/index.js | 120 +++++++++++++++++++++++----------------------- server/server.js | 12 +++-- webpack.config.js | 13 +++-- 5 files changed, 89 insertions(+), 72 deletions(-) diff --git a/client/index.js b/client/index.js index 249da95e..d4e4e6fe 100644 --- a/client/index.js +++ b/client/index.js @@ -109,9 +109,18 @@ export default class Nullstack { if (module.hot) { const socket = new WebSocket('ws' + window.location.origin.slice(4) + '/ws'); - socket.onmessage = function (e) { - if (e.data.indexOf('still-ok') > -1) { - window.location.reload() + window.lastHash + socket.onmessage = async function (e) { + const data = JSON.parse(e.data) + if (data.type === 'NULLSTACK_SERVER_STARTED') { + window.needsReload && 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) { diff --git a/package.json b/package.json index 41bb60ca..0357da06 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "fs-extra": "10.1.0", "mini-css-extract-plugin": "2.6.0", "node-fetch": "2.6.7", + "nodemon-webpack-plugin": "^4.8.1", "sass": "1.51.0", "sass-loader": "8.0.2", "swc-loader": "0.2.1", diff --git a/scripts/index.js b/scripts/index.js index 119ef188..e8222904 100755 --- a/scripts/index.js +++ b/scripts/index.js @@ -21,74 +21,76 @@ function getServerCompiler(options) { return webpack(getConfig(options)[0]) } -function getClientCompiler(options) { - return webpack(getConfig(options)[1]) -} - -async function start({ input, port, env, mode = 'ssr', hot }) { - const environment = 'development'; - const serverCompiler = getServerCompiler({ environment, input }); - let clientStarted = false +function loadEnv(env) { let envPath = '.env' if (env) { envPath += `.${process.env.NULLSTACK_ENVIRONMENT_NAME}` } dotenv.config({ path: envPath }) - if (!port) { - port = process.env['NULLSTACK_SERVER_PORT'] || process.env['PORT'] || 3000 - } - process.env['NULLSTACK_ENVIRONMENT_MODE'] = mode - console.log(` πŸš€οΈ Starting your application in ${environment} mode...`); - const WebpackDevServer = require('webpack-dev-server'); +} + +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 +} + +async function start({ input, port, env, mode = 'spa', hot }) { + loadEnv(env) const { setLogLevel } = require('webpack/hot/log') + const WebpackDevServer = require('webpack-dev-server'); 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] + const devServerOptions = { + hot: !!hot, + open: false, + devMiddleware: { + index: false + }, + client: { + overlay: { errors: true, warnings: false }, + logging: 'none', + progress: false, + }, + proxy: { + context: () => true, + target: `http://localhost:${process.env['NULSTACK_SERVER_PORT_YOU_SHOULD_NOT_CARE_ABOUT']}` + }, + webSocketServer: require.resolve('./socket'), + port: process.env['NULLSTACK_SERVER_PORT'] + }; + const environment = 'development' + const clientCompiler = getCompiler({ environment, input }); + const server = new WebpackDevServer(devServerOptions, clientCompiler); + const serverCompiler = getServerCompiler({ environment, input }); + let once = false serverCompiler.watch({}, (error, stats) => { - if (stats.hasErrors()) { - console.log(`\n πŸ’₯️ There is an error preventing compilation`); - } else { - console.log('\x1b[36m%s\x1b[0m', `\n βœ…οΈ Your application is ready at http://localhost:${port}\n`); - } - const bundlePath = path.resolve(process.cwd(), '.development/server.js') - delete require.cache[require.resolve(bundlePath)] - if (!clientStarted) { - clientStarted = true - const devServerOptions = { - hot: !!hot, - open: false, - devMiddleware: { - index: false - }, - client: { - overlay: true, - logging: 'none', - progress: false, - }, - setupMiddlewares: (middlewares, devServer) => { - if (!devServer) { - throw new Error('webpack-dev-server is not defined'); - } - middlewares.unshift((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}`) - } - } - const serverBundle = require(bundlePath) - const server = serverBundle.default.server - server.less = true - server.port = port - server.start() - server(req, res, next) - }); - return middlewares; - }, - port - }; - const clientCompiler = getClientCompiler({ environment, input }); - const server = new WebpackDevServer(devServerOptions, clientCompiler); + if (!once) { server.start(); + once = true + } + if (stats.hasErrors()) { + console.log(stats.toString({ colors: true })) } }); } diff --git a/server/server.js b/server/server.js index 55f23641..903a3547 100644 --- a/server/server.js +++ b/server/server.js @@ -11,12 +11,12 @@ import { generateFile } from './files'; 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'; if (!global.fetch) { global.fetch = fetch; @@ -24,7 +24,7 @@ if (!global.fetch) { const server = express(); -server.port ??= process.env['NULLSTACK_SERVER_PORT'] || process.env['PORT'] || 3000 +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 @@ -215,9 +215,11 @@ server.start = function () { } server.listen(server.port, () => { - if (environment.production) { - const name = project.name ? project.name : 'Nullstack' - console.log('\x1b[36m%s\x1b[0m', ` βœ…οΈ ${name} is ready at http://127.0.0.1:${server.port}\n`); + if (environment.development) { + const socket = new WebSocket(`ws://localhost:${process.env['NULLSTACK_SERVER_PORT']}/ws`); + socket.onopen = async function (e) { + socket.send('{"type":"NULLSTACK_SERVER_STARTED"}') + } } }); } diff --git a/webpack.config.js b/webpack.config.js index 925396a9..67abb2cc 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -3,7 +3,7 @@ const MiniCssExtractPlugin = require("mini-css-extract-plugin"); const TerserPlugin = require('terser-webpack-plugin'); const crypto = require("crypto"); const { readdirSync } = require('fs'); -const { HotModuleReplacementPlugin } = require('webpack') +const NodemonPlugin = require('nodemon-webpack-plugin'); const buildKey = crypto.randomBytes(20).toString('hex'); @@ -117,7 +117,6 @@ function server(env, argv) { const minimize = !isDev; const plugins = [] return { - name: 'server', mode: argv.environment, entry: './server.js', output: { @@ -238,11 +237,15 @@ function client(env, argv) { }) ] if (isDev) { - plugins.push(new HotModuleReplacementPlugin()) + plugins.push(new NodemonPlugin({ + ext: '*', + watch: [".env", ".env.*", './.development/*.*'], + script: './.development/server.js', + nodeArgs: ['--enable-source-maps'], + quiet: true + })) } return { - name: 'client', - dependencies: ['server'], infrastructureLogging: { level: 'none', }, From 77de101e969e5e7e4695b06fde76f1f2e706eedf Mon Sep 17 00:00:00 2001 From: Christian Mortaro Date: Sat, 30 Jul 2022 17:01:57 -0300 Subject: [PATCH 088/105] :zap: improved dev server --- scripts/index.js | 32 ++++++++++++++++++---- scripts/socket.js | 70 +++++++++++++++++++++++++++++++++++++++++++++++ webpack.config.js | 13 +++++++-- 3 files changed, 108 insertions(+), 7 deletions(-) create mode 100644 scripts/socket.js diff --git a/scripts/index.js b/scripts/index.js index e8222904..3be6a5b2 100755 --- a/scripts/index.js +++ b/scripts/index.js @@ -52,10 +52,14 @@ function getPort(port) { } async function start({ input, port, env, mode = 'spa', hot }) { + const environment = 'development' + console.log(` πŸš€οΈ Starting your application in ${environment} mode...`); loadEnv(env) - const { setLogLevel } = require('webpack/hot/log') const WebpackDevServer = require('webpack-dev-server'); - setLogLevel('none') + const { setLogLevel: setClientLogLevel } = require('webpack/hot/log') + setClientLogLevel('none') + // const { setLogLevel: setServerLogLevel } = require('webpack-dev-server/client/utils/log') + // setServerLogLevel('none') process.env['NULLSTACK_ENVIRONMENT_MODE'] = mode process.env['NULLSTACK_SERVER_PORT'] = getPort(port) const ports = await getFreePorts() @@ -65,7 +69,8 @@ async function start({ input, port, env, mode = 'spa', hot }) { hot: !!hot, open: false, devMiddleware: { - index: false + index: false, + stats: 'none' }, client: { overlay: { errors: true, warnings: false }, @@ -76,17 +81,34 @@ async function start({ input, port, env, mode = 'spa', hot }) { context: () => true, target: `http://localhost:${process.env['NULSTACK_SERVER_PORT_YOU_SHOULD_NOT_CARE_ABOUT']}` }, + setupMiddlewares: (middlewares, devServer) => { + if (!devServer) { + throw new Error('webpack-dev-server is not defined'); + } + middlewares.unshift((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}`) + } + } + next() + }); + return middlewares; + }, webSocketServer: require.resolve('./socket'), port: process.env['NULLSTACK_SERVER_PORT'] }; - const environment = 'development' const clientCompiler = getCompiler({ environment, input }); const server = new WebpackDevServer(devServerOptions, clientCompiler); const serverCompiler = getServerCompiler({ environment, input }); let once = false serverCompiler.watch({}, (error, stats) => { if (!once) { - server.start(); + server.startCallback(() => { + console.log('\x1b[36m%s\x1b[0m', ` βœ…οΈ Your application is ready at http://localhost:${process.env['NULLSTACK_SERVER_PORT']}\n`); + }); once = true } if (stats.hasErrors()) { 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/webpack.config.js b/webpack.config.js index 67abb2cc..bc75876c 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -7,6 +7,12 @@ const NodemonPlugin = require('nodemon-webpack-plugin'); const buildKey = crypto.randomBytes(20).toString('hex'); +customConsole = new Proxy({}, { + get() { + return () => { } + } +}) + function getLoader(loader) { const loaders = path.resolve('./node_modules/nullstack/loaders'); return path.join(loaders, loader); @@ -118,6 +124,9 @@ function server(env, argv) { const plugins = [] return { mode: argv.environment, + infrastructureLogging: { + console: customConsole, + }, entry: './server.js', output: { path: path.join(dir, folder), @@ -234,7 +243,7 @@ function client(env, argv) { new MiniCssExtractPlugin({ filename: "client.css", chunkFilename: '[chunkhash].client.css' - }) + }), ] if (isDev) { plugins.push(new NodemonPlugin({ @@ -247,7 +256,7 @@ function client(env, argv) { } return { infrastructureLogging: { - level: 'none', + console: customConsole, }, mode: argv.environment, entry: './client.js', From ae9d11b38a6d1d1c30757b51262a110a3a34a460 Mon Sep 17 00:00:00 2001 From: Christian Mortaro Date: Sun, 31 Jul 2022 14:56:37 -0300 Subject: [PATCH 089/105] :zap: fast ssr reload --- client/index.js | 2 +- scripts/index.js | 9 ++++----- server/environment.js | 3 +-- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/client/index.js b/client/index.js index d4e4e6fe..542907e3 100644 --- a/client/index.js +++ b/client/index.js @@ -113,7 +113,7 @@ if (module.hot) { socket.onmessage = async function (e) { const data = JSON.parse(e.data) if (data.type === 'NULLSTACK_SERVER_STARTED') { - window.needsReload && window.location.reload() + (window.needsReload || !environment.hot) && window.location.reload() } else if (data.type === 'hash') { const newHash = data.data.slice(20) if (newHash === window.lastHash) { diff --git a/scripts/index.js b/scripts/index.js index 3be6a5b2..9b13c957 100755 --- a/scripts/index.js +++ b/scripts/index.js @@ -56,17 +56,16 @@ async function start({ input, port, env, mode = 'spa', hot }) { console.log(` πŸš€οΈ Starting your application in ${environment} mode...`); loadEnv(env) const WebpackDevServer = require('webpack-dev-server'); - const { setLogLevel: setClientLogLevel } = require('webpack/hot/log') - setClientLogLevel('none') - // const { setLogLevel: setServerLogLevel } = require('webpack-dev-server/client/utils/log') - // setServerLogLevel('none') + 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'] = (!!hot).toString() const devServerOptions = { - hot: !!hot, + hot: 'only', open: false, devMiddleware: { index: false, diff --git a/server/environment.js b/server/environment.js index 005dcb23..1345ea2f 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; @@ -10,6 +8,7 @@ environment.mode = process.env.NULLSTACK_ENVIRONMENT_MODE || 'ssr'; environment.key = "{{NULLSTACK_ENVIRONMENT_KEY}}" environment.name = process.env.NULLSTACK_ENVIRONMENT_NAME || ''; +environment.hot = process.env.NULLSTACK_ENVIRONMENT_HOT === 'true' Object.freeze(environment); From f753e93d46a9d27c31f12f71cfa088a0f58199b0 Mon Sep 17 00:00:00 2001 From: Christian Mortaro Date: Tue, 2 Aug 2022 01:25:10 -0300 Subject: [PATCH 090/105] :bug: fix static scopes --- loaders/register-static-from-server.js | 1 + scripts/index.js | 5 ++++- server/index.js | 12 ++++++++++-- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/loaders/register-static-from-server.js b/loaders/register-static-from-server.js index e9be0720..c299c3c5 100644 --- a/loaders/register-static-from-server.js +++ b/loaders/register-static-from-server.js @@ -35,6 +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/scripts/index.js b/scripts/index.js index 9b13c957..77c8afdc 100755 --- a/scripts/index.js +++ b/scripts/index.js @@ -75,10 +75,13 @@ async function start({ input, port, env, mode = 'spa', hot }) { overlay: { errors: true, warnings: false }, logging: 'none', progress: false, + reconnect: true }, proxy: { context: () => true, - target: `http://localhost:${process.env['NULSTACK_SERVER_PORT_YOU_SHOULD_NOT_CARE_ABOUT']}` + target: `http://localhost:${process.env['NULSTACK_SERVER_PORT_YOU_SHOULD_NOT_CARE_ABOUT']}`, + proxyTimeout: 10 * 60 * 1000, + timeout: 10 * 60 * 1000, }, setupMiddlewares: (middlewares, devServer) => { if (!devServer) { diff --git a/server/index.js b/server/index.js index 6d4e7674..87743ad0 100644 --- a/server/index.js +++ b/server/index.js @@ -40,15 +40,23 @@ class Nullstack { 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] } - async function _invoke(params = {}) { + 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 await klass[propName].call(klass, context); + return klass[propName].call(klass, context); } klass[prop] = _invoke klass.prototype[prop] = _invoke From 2916fd4fb52601571ce76c4f304967fc4d14af56 Mon Sep 17 00:00:00 2001 From: Christian Mortaro Date: Mon, 8 Aug 2022 23:04:04 -0300 Subject: [PATCH 091/105] :construction: extension mode --- client/index.js | 2 +- client/router.js | 2 +- package.json | 5 +++-- scripts/index.js | 34 +++++++++++++------------------ server/environment.js | 5 ++++- server/project.js | 15 +++++++++++--- server/server.js | 13 +++++++++++- tests/package.json | 2 +- tests/src/ServerFunctions.njs | 2 +- tests/src/ServerFunctions.test.js | 12 +++++++---- webpack.config.js | 20 ++++++++++++------ 11 files changed, 71 insertions(+), 41 deletions(-) diff --git a/client/index.js b/client/index.js index 542907e3..1f4862b3 100644 --- a/client/index.js +++ b/client/index.js @@ -108,7 +108,7 @@ export default class Nullstack { } if (module.hot) { - const socket = new WebSocket('ws' + window.location.origin.slice(4) + '/ws'); + const socket = new WebSocket('ws' + router.base.slice(4) + '/ws'); window.lastHash socket.onmessage = async function (e) { const data = JSON.parse(e.data) diff --git a/client/router.js b/client/router.js index b9f5a82b..28e2415d 100644 --- a/client/router.js +++ b/client/router.js @@ -90,7 +90,7 @@ class Router { get base() { if (this._base) return this._base - this._base = window.location.origin + this._base = new URL(document.querySelector('[rel="canonical"]').href).origin return this._base } diff --git a/package.json b/package.json index 0357da06..f1006209 100644 --- a/package.json +++ b/package.json @@ -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", @@ -33,4 +34,4 @@ "webpack-dev-server": "4.9.0", "ws": "7.5.7" } -} \ No newline at end of file +} diff --git a/scripts/index.js b/scripts/index.js index 77c8afdc..66bbc597 100755 --- a/scripts/index.js +++ b/scripts/index.js @@ -17,10 +17,6 @@ function getCompiler(options) { return webpack(getConfig(options)) } -function getServerCompiler(options) { - return webpack(getConfig(options)[0]) -} - function loadEnv(env) { let envPath = '.env' if (env) { @@ -64,22 +60,27 @@ async function start({ input, port, env, mode = 'spa', hot }) { 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'] = (!!hot).toString() + process.env['NULLSTACK_PROJECT_DOMAIN'] ??= 'localhost' + process.env['NULLSTACK_WORKER_PROTOCOL'] ??= 'http' const devServerOptions = { hot: 'only', open: false, + host: process.env['NULLSTACK_PROJECT_DOMAIN'], devMiddleware: { index: false, - stats: 'none' + stats: 'none', + writeToDisk: true, }, client: { overlay: { errors: true, warnings: false }, logging: 'none', progress: false, - reconnect: true + 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: `http://localhost:${process.env['NULSTACK_SERVER_PORT_YOU_SHOULD_NOT_CARE_ABOUT']}`, + target: `${process.env['NULLSTACK_WORKER_PROTOCOL']}://${process.env['NULLSTACK_PROJECT_DOMAIN']}:${process.env['NULSTACK_SERVER_PORT_YOU_SHOULD_NOT_CARE_ABOUT']}`, proxyTimeout: 10 * 60 * 1000, timeout: 10 * 60 * 1000, }, @@ -104,19 +105,13 @@ async function start({ input, port, env, mode = 'spa', hot }) { }; const clientCompiler = getCompiler({ environment, input }); const server = new WebpackDevServer(devServerOptions, clientCompiler); - const serverCompiler = getServerCompiler({ environment, input }); let once = false - serverCompiler.watch({}, (error, stats) => { - if (!once) { - server.startCallback(() => { - console.log('\x1b[36m%s\x1b[0m', ` βœ…οΈ Your application is ready at http://localhost:${process.env['NULLSTACK_SERVER_PORT']}\n`); - }); - once = true - } - if (stats.hasErrors()) { - console.log(stats.toString({ colors: true })) - } - }); + if (!once) { + 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`); + }); + once = true + } } function build({ input, output, cache, env, mode = 'ssr' }) { @@ -143,7 +138,6 @@ program .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('--hot', 'Enable hot module replacement') .helpOption('-h, --help', 'Learn more about this command') diff --git a/server/environment.js b/server/environment.js index 1345ea2f..7f74e2b4 100644 --- a/server/environment.js +++ b/server/environment.js @@ -8,7 +8,10 @@ environment.mode = process.env.NULLSTACK_ENVIRONMENT_MODE || 'ssr'; environment.key = "{{NULLSTACK_ENVIRONMENT_KEY}}" environment.name = process.env.NULLSTACK_ENVIRONMENT_NAME || ''; -environment.hot = process.env.NULLSTACK_ENVIRONMENT_HOT === 'true' + +if (environment.development) { + environment.hot = process.env.NULLSTACK_ENVIRONMENT_HOT === 'true' +} Object.freeze(environment); diff --git a/server/project.js b/server/project.js index 27d4e693..83915cbf 100644 --- a/server/project.js +++ b/server/project.js @@ -1,6 +1,6 @@ import environment from './environment'; import worker from './worker'; -import server from './server'; +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/server.js b/server/server.js index 903a3547..cee880dc 100644 --- a/server/server.js +++ b/server/server.js @@ -17,6 +17,7 @@ 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; @@ -204,7 +205,12 @@ server.start = function () { 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 } }); @@ -214,12 +220,17 @@ server.start = function () { process.exit(); } - server.listen(server.port, () => { + server.listen(server.port, async () => { if (environment.development) { + 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', ` βœ…οΈ Your application is ready at http://${process.env['NULLSTACK_PROJECT_DOMAIN']}:${process.env['NULLSTACK_SERVER_PORT']}\n`); } }); } diff --git a/tests/package.json b/tests/package.json index e6cbb192..422014c6 100644 --- a/tests/package.json +++ b/tests/package.json @@ -15,7 +15,7 @@ "scripts": { "start": "npx nullstack start --input=./tests --port=6969 --env=test --mode=spa --hot", "build": "npx nullstack build --input=./tests --env=test", - "test": "npm run build && jest", + "test": "npm run build && jest --runInBand", "script": "node src/scripts/run.js" } } \ No newline at end of file diff --git a/tests/src/ServerFunctions.njs b/tests/src/ServerFunctions.njs index 02e84063..325817a5 100644 --- a/tests/src/ServerFunctions.njs +++ b/tests/src/ServerFunctions.njs @@ -84,7 +84,7 @@ class ServerFunctions extends Nullstack { render() { return ( -
    +
    diff --git a/tests/src/ServerFunctions.test.js b/tests/src/ServerFunctions.test.js index 85e0a38f..4e40f30c 100644 --- a/tests/src/ServerFunctions.test.js +++ b/tests/src/ServerFunctions.test.js @@ -1,10 +1,12 @@ -beforeAll(async () => { - await page.goto('http://localhost:6969/server-functions'); -}); - describe('ServerFunctions', () => { + beforeEach(async () => { + await page.goto('http://localhost:6969/server-functions'); + }); + + test('instance can use returned values', async () => { + await page.waitForSelector('[data-hydrated]') await page.click('.set-count-to-one'); await page.waitForSelector('[data-count="1"]'); const element = await page.$('[data-count="1"]'); @@ -12,6 +14,7 @@ describe('ServerFunctions', () => { }); test('server functions accept an object as argument', async () => { + await page.waitForSelector('[data-hydrated]') await page.click('.set-count-to-two'); await page.waitForSelector('[data-count="2"]'); const element = await page.$('[data-count="2"]'); @@ -19,6 +22,7 @@ describe('ServerFunctions', () => { }); test('server functions serialize and deserialize dates', async () => { + await page.waitForSelector('[data-hydrated]') await page.click('.set-date'); await page.waitForSelector('[data-year="1992"]'); const element = await page.$('[data-year="1992"]'); diff --git a/webpack.config.js b/webpack.config.js index bc75876c..55a90dbf 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -4,6 +4,7 @@ const TerserPlugin = require('terser-webpack-plugin'); const crypto = require("crypto"); const { readdirSync } = require('fs'); const NodemonPlugin = require('nodemon-webpack-plugin'); +const CopyPlugin = require("copy-webpack-plugin"); const buildKey = crypto.randomBytes(20).toString('hex'); @@ -122,6 +123,15 @@ function server(env, argv) { const devtool = isDev ? 'inline-cheap-module-source-map' : false; const minimize = !isDev; const plugins = [] + if (isDev) { + plugins.push(new NodemonPlugin({ + ext: '*', + watch: [".env", ".env.*", './.development/server.js'], + script: './.development/server.js', + nodeArgs: ['--enable-source-maps'], + quiet: true + })) + } return { mode: argv.environment, infrastructureLogging: { @@ -246,12 +256,10 @@ function client(env, argv) { }), ] if (isDev) { - plugins.push(new NodemonPlugin({ - ext: '*', - watch: [".env", ".env.*", './.development/*.*'], - script: './.development/server.js', - nodeArgs: ['--enable-source-maps'], - quiet: true + plugins.push(new CopyPlugin({ + patterns: [ + { from: "public", to: "../.development" }, + ] })) } return { From b96c6f413f5ece4dccdf2a30054ec299ca004e30 Mon Sep 17 00:00:00 2001 From: Christian Mortaro Date: Mon, 8 Aug 2022 23:35:56 -0300 Subject: [PATCH 092/105] :white_check_mark: tests --- client/ref.js | 2 +- tests/src/Context.njs | 47 ++++++++++++++++++++++++++++++++------- tests/src/Context.test.js | 36 ++++++++++++++++++++++++++++++ tests/src/Refs.njs | 5 +++-- tests/src/Refs.test.js | 7 ++++++ 5 files changed, 86 insertions(+), 11 deletions(-) diff --git a/client/ref.js b/client/ref.js index a9d2b829..5cb9775f 100644 --- a/client/ref.js +++ b/client/ref.js @@ -5,7 +5,7 @@ function setup(attributes, element) { const property = attributes.ref.property if (typeof object[property] === 'function') { setTimeout(() => { - object[property]({ element }) + object[property]({ ...attributes, element }) }, 0) } else { object[property] = element 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/Refs.njs b/tests/src/Refs.njs index ea08a5af..35a59b48 100644 --- a/tests/src/Refs.njs +++ b/tests/src/Refs.njs @@ -8,7 +8,8 @@ class Refs extends Nullstack { this.id = this._element.id } - setRef({ element, refInstanceCount }) { + setRef({ element, refInstanceCount, id }) { + this.refReceivedProps = element.id === id this._function = element this.isOnDOM = element.offsetHeight > 0 && refInstanceCount } @@ -29,7 +30,7 @@ class Refs extends Nullstack { - span + span
    diff --git a/tests/src/Refs.test.js b/tests/src/Refs.test.js index 71a38e1a..b0069952 100644 --- a/tests/src/Refs.test.js +++ b/tests/src/Refs.test.js @@ -35,6 +35,13 @@ describe('Refs', () => { expect(element).toBeTruthy(); }); + test('refs functions receive attributes as argument', async () => { + await page.waitForSelector('[data-ref-received-props]'); + const element = await page.$('[data-ref-received-props]'); + expect(element).toBeTruthy(); + }); + + test('refs functions only run after the element is appended do DOM', async () => { await page.waitForSelector('[data-dom="0"]'); const element = await page.$('[data-dom="0"]'); From ed2a45cc4e31dd0402c69e732accdcf6568a4818 Mon Sep 17 00:00:00 2001 From: Christian Mortaro Date: Tue, 9 Aug 2022 00:55:23 -0300 Subject: [PATCH 093/105] :sparkles: dev server port check --- scripts/index.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/scripts/index.js b/scripts/index.js index 66bbc597..7b9cbe28 100755 --- a/scripts/index.js +++ b/scripts/index.js @@ -105,13 +105,12 @@ async function start({ input, port, env, mode = 'spa', hot }) { }; const clientCompiler = getCompiler({ environment, input }); const server = new WebpackDevServer(devServerOptions, clientCompiler); - let once = false - if (!once) { + 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`); }); - once = true - } + }) } function build({ input, output, cache, env, mode = 'ssr' }) { From c671ff90fa0d9a8a597d7f17cf70fc0ee52d7f8a Mon Sep 17 00:00:00 2001 From: Christian Mortaro Date: Tue, 9 Aug 2022 01:35:45 -0300 Subject: [PATCH 094/105] :wrench: lower node requirement --- scripts/index.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/index.js b/scripts/index.js index 7b9cbe28..b89dc4af 100755 --- a/scripts/index.js +++ b/scripts/index.js @@ -60,8 +60,8 @@ async function start({ input, port, env, mode = 'spa', hot }) { 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'] = (!!hot).toString() - process.env['NULLSTACK_PROJECT_DOMAIN'] ??= 'localhost' - process.env['NULLSTACK_WORKER_PROTOCOL'] ??= 'http' + 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 devServerOptions = { hot: 'only', open: false, From 0cbb3444e2d661d25245b457985298f4fb657c16 Mon Sep 17 00:00:00 2001 From: Jonathan Bertoldi Date: Tue, 9 Aug 2022 09:02:48 -0300 Subject: [PATCH 095/105] Fix undefined cb data breaking the server --- server/server.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/server.js b/server/server.js index cee880dc..443c2cd3 100644 --- a/server/server.js +++ b/server/server.js @@ -94,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; @@ -237,4 +237,4 @@ server.start = function () { } -export default server; \ No newline at end of file +export default server; From 5efe0ff9d66c57db4b9d6b87452261b13cc9d4d8 Mon Sep 17 00:00:00 2001 From: Christian Mortaro Date: Sun, 14 Aug 2022 15:47:40 -0300 Subject: [PATCH 096/105] :wrench: wait for server ping --- scripts/index.js | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/scripts/index.js b/scripts/index.js index b89dc4af..3a5a7513 100755 --- a/scripts/index.js +++ b/scripts/index.js @@ -8,6 +8,7 @@ const { existsSync } = 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') function getConfig(options) { return config.map((env) => env(null, options)) @@ -62,6 +63,7 @@ async function start({ input, port, env, mode = 'spa', hot }) { process.env['NULLSTACK_ENVIRONMENT_HOT'] = (!!hot).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 devServerOptions = { hot: 'only', open: false, @@ -80,7 +82,7 @@ async function start({ input, port, env, mode = 'spa', hot }) { }, proxy: { context: () => true, - target: `${process.env['NULLSTACK_WORKER_PROTOCOL']}://${process.env['NULLSTACK_PROJECT_DOMAIN']}:${process.env['NULSTACK_SERVER_PORT_YOU_SHOULD_NOT_CARE_ABOUT']}`, + target, proxyTimeout: 10 * 60 * 1000, timeout: 10 * 60 * 1000, }, @@ -88,7 +90,7 @@ async function start({ input, port, env, mode = 'spa', hot }) { if (!devServer) { throw new Error('webpack-dev-server is not defined'); } - middlewares.unshift((req, res, next) => { + middlewares.unshift(async (req, res, next) => { if (req.originalUrl.indexOf('.hot-update.') === -1) { if (req.originalUrl.startsWith('/nullstack/')) { console.log(` βš™οΈ [${req.method}] ${req.originalUrl}`) @@ -96,7 +98,22 @@ async function start({ input, port, env, mode = 'spa', hot }) { console.log(` πŸ•ΈοΈ [${req.method}] ${req.originalUrl}`) } } - next() + 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; }, From 2ab0a1cc1b292ccf74f6a8e8c9999af3d1593796 Mon Sep 17 00:00:00 2001 From: Christian Mortaro Date: Sun, 14 Aug 2022 20:34:09 -0300 Subject: [PATCH 097/105] :wrench: configure test timeout --- tests/jest.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/jest.config.js b/tests/jest.config.js index f41e41d6..484790b0 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: 10000 } \ No newline at end of file From aa0e8d0ad255933b0c1e572595aab65a1ed67c74 Mon Sep 17 00:00:00 2001 From: Christian Mortaro Date: Sun, 14 Aug 2022 20:43:52 -0300 Subject: [PATCH 098/105] :wrench: configure test timeout --- tests/jest.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/jest.config.js b/tests/jest.config.js index 484790b0..19b8bcc5 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: 10000 + testTimeout: 20000 } \ No newline at end of file From 0ad51a522616a67ec24ffc0a329fdbf02ab91a40 Mon Sep 17 00:00:00 2001 From: Christian Mortaro Date: Sun, 14 Aug 2022 20:51:53 -0300 Subject: [PATCH 099/105] :wrench: configure test timeout --- tests/jest.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/jest.config.js b/tests/jest.config.js index 19b8bcc5..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: 20000 + testTimeout: 60000 } \ No newline at end of file From f1e6dde6b939fa98f5130b8918011c1a806d1cdd Mon Sep 17 00:00:00 2001 From: Christian Mortaro Date: Tue, 16 Aug 2022 20:58:33 -0300 Subject: [PATCH 100/105] :sparkles: client.js hot reload --- loaders/inject-hmr.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/loaders/inject-hmr.js b/loaders/inject-hmr.js index 6c6998bc..8d325d03 100644 --- a/loaders/inject-hmr.js +++ b/loaders/inject-hmr.js @@ -26,6 +26,11 @@ module.exports = function (source) { return source + ` if (module.hot) { + if (window.needsClientReload) { + window.location.reload() + } + module.hot.accept() + window.needsClientReload = true module.hot.accept('${klassPath}', () => { Nullstack.hotReload(${klassName}) }) From 8048850565251c81e65447c9ee65e841b85fea82 Mon Sep 17 00:00:00 2001 From: Maycon Sousa Date: Fri, 19 Aug 2022 11:44:58 -0300 Subject: [PATCH 101/105] allow zero when binding value + type fixes --- plugins/bindable.js | 2 +- types/JSX.d.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/bindable.js b/plugins/bindable.js index 7d7174f2..4d1b29bd 100644 --- a/plugins/bindable.js +++ b/plugins/bindable.js @@ -44,7 +44,7 @@ function transform({ node, environment }) { } else if (node.type === 'input' && node.attributes.type === 'checkbox') { node.attributes.checked = target[node.attributes.bind]; } else { - node.attributes.value = target[node.attributes.bind] || ''; + node.attributes.value = target[node.attributes.bind] ?? ''; } node.attributes.name = node.attributes.name || node.attributes.bind; diff --git a/types/JSX.d.ts b/types/JSX.d.ts index 9e7b4461..97e0f1de 100644 --- a/types/JSX.d.ts +++ b/types/JSX.d.ts @@ -631,7 +631,7 @@ export interface HTMLAttributes extends AriaAttributes, DOMAttributes { placeholder?: string | undefined; slot?: string | undefined; spellcheck?: Booleanish | undefined; - style?: object | undefined; + style?: string | undefined; tabindex?: number | string | undefined; title?: string | undefined; translate?: "yes" | "no" | undefined; @@ -1260,7 +1260,7 @@ export interface SVGAttributes extends AriaAttributes, DOMAttributes { method?: string | undefined; min?: number | string | undefined; name?: string | undefined; - style?: object | undefined; + style?: string | undefined; target?: string | undefined; type?: string | undefined; width?: number | string | undefined; From 9d5f83a9a656195b76f48277756d510f1835a065 Mon Sep 17 00:00:00 2001 From: Christian Mortaro Date: Fri, 19 Aug 2022 23:52:49 -0300 Subject: [PATCH 102/105] :fire: remove old segment logic --- plugins/routable.js | 8 -------- shared/generateTree.js | 18 ------------------ 2 files changed, 26 deletions(-) diff --git a/plugins/routable.js b/plugins/routable.js index 61620db5..a8c20737 100644 --- a/plugins/routable.js +++ b/plugins/routable.js @@ -16,13 +16,6 @@ function match(node) { function load({ router }) { router._routes = {}; - if (!router._oldSegments) { - router._oldSegments = {}; - router._newSegments = {}; - } else { - router._oldSegments = router._newSegments; - router._newSegments = {}; - } } function transform({ node, depth, router }) { @@ -34,7 +27,6 @@ function transform({ node, depth, router }) { const params = routeMatches(router.url, node.attributes.route); if (params) { router._routes[routeDepth] = true; - router._newSegments[routeDepth] = params; Object.assign(router._segments, params); } else { erase(node); diff --git a/shared/generateTree.js b/shared/generateTree.js index 84e18ac1..79ba71dc 100644 --- a/shared/generateTree.js +++ b/shared/generateTree.js @@ -27,24 +27,6 @@ async function generateBranch(siblings, node, depth, scope) { if (isClass(node)) { const key = generateKey(scope, node, depth) - if ( - scope.context.environment.client && - scope.context.router._changed && - node.attributes && - node.attributes.route && - scope.context.environment.mode !== 'ssg' - ) { - const routeDepth = depth.slice(0, depth.lastIndexOf('-')) - 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 instance = scope.instances[key] || new node.type(scope); instance.persistent = !!node.attributes.persistent instance.key = key; From fd631de13c09b5279e12814da402771dcd5868af Mon Sep 17 00:00:00 2001 From: Christian Mortaro Date: Sat, 20 Aug 2022 01:15:54 -0300 Subject: [PATCH 103/105] :bug: fix livereload bug --- client/index.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/client/index.js b/client/index.js index 1f4862b3..887bd72c 100644 --- a/client/index.js +++ b/client/index.js @@ -134,9 +134,7 @@ if (module.hot) { } Nullstack.hotReload = function hotReload(klass) { if (client.skipHotReplacement) { - setInterval(() => { - fetch(window.location.href).then((r) => r.status !== 500 && window.location.reload()) - }, 100) + window.location.reload() } else { Nullstack.start(klass); windowEvent('environment'); From c1d09a2c5aaf3fb3e1b93eb51c55afc4fb29c0e3 Mon Sep 17 00:00:00 2001 From: Christian Mortaro Date: Sun, 21 Aug 2022 13:33:00 -0300 Subject: [PATCH 104/105] :wrench: finalize cli options --- scripts/index.js | 27 ++++++++++++++++++++------- server/server.js | 8 +++++--- tests/package.json | 2 +- webpack.config.js | 3 ++- 4 files changed, 28 insertions(+), 12 deletions(-) diff --git a/scripts/index.js b/scripts/index.js index 3a5a7513..db3e0361 100755 --- a/scripts/index.js +++ b/scripts/index.js @@ -4,7 +4,7 @@ const { version } = require('../package.json'); 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') @@ -48,7 +48,7 @@ function getPort(port) { return port || process.env['NULLSTACK_SERVER_PORT'] || process.env['PORT'] || 3000 } -async function start({ input, port, env, mode = 'spa', hot }) { +async function start({ input, port, env, mode = 'spa', cold, disk }) { const environment = 'development' console.log(` πŸš€οΈ Starting your application in ${environment} mode...`); loadEnv(env) @@ -60,10 +60,12 @@ async function start({ input, port, env, mode = 'spa', hot }) { 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'] = (!!hot).toString() + 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, @@ -71,7 +73,7 @@ async function start({ input, port, env, mode = 'spa', hot }) { devMiddleware: { index: false, stats: 'none', - writeToDisk: true, + writeToDisk, }, client: { overlay: { errors: true, warnings: false }, @@ -120,8 +122,18 @@ async function start({ input, port, env, mode = 'spa', hot }) { webSocketServer: require.resolve('./socket'), port: process.env['NULLSTACK_SERVER_PORT'] }; - const clientCompiler = getCompiler({ environment, input }); - const server = new WebpackDevServer(devServerOptions, clientCompiler); + const compiler = getCompiler({ environment, input, disk }); + const outputPath = compiler.compilers[0].outputPath; + 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; + }); + } + }); + const server = new WebpackDevServer(devServerOptions, compiler); const portChecker = require('express')().listen(process.env['NULLSTACK_SERVER_PORT'], () => { portChecker.close() server.startCallback(() => { @@ -155,7 +167,8 @@ program .option('-p, --port ', 'Port number to run the server') .option('-i, --input ', 'Path to project that will be started') .option('-e, --env ', 'Name of the environment file that should be loaded') - .option('--hot', 'Enable hot module replacement') + .option('-d, --disk', 'Write files to disk') + .option('-c, --cold', 'Disable hot module replacement') .helpOption('-h, --help', 'Learn more about this command') .action(start) diff --git a/server/server.js b/server/server.js index 443c2cd3..919b1f83 100644 --- a/server/server.js +++ b/server/server.js @@ -222,9 +222,11 @@ server.start = function () { server.listen(server.port, async () => { if (environment.development) { - const content = await server.prerender('/'); - const target = process.cwd() + `/.development/index.html` - writeFileSync(target, content) + 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"}') diff --git a/tests/package.json b/tests/package.json index 422014c6..c73a7a44 100644 --- a/tests/package.json +++ b/tests/package.json @@ -13,7 +13,7 @@ "purgecss-webpack-plugin": "^4.1.3" }, "scripts": { - "start": "npx nullstack start --input=./tests --port=6969 --env=test --mode=spa --hot", + "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" diff --git a/webpack.config.js b/webpack.config.js index 55a90dbf..51797d67 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -244,6 +244,7 @@ function server(env, argv) { } function client(env, argv) { + const disk = !!argv.disk const dir = argv.input ? path.join(__dirname, argv.input) : process.cwd(); const isDev = argv.environment === 'development'; const folder = isDev ? '.development' : '.production'; @@ -255,7 +256,7 @@ function client(env, argv) { chunkFilename: '[chunkhash].client.css' }), ] - if (isDev) { + if (disk) { plugins.push(new CopyPlugin({ patterns: [ { from: "public", to: "../.development" }, From 439b80f0f5e59d36008fefbfccc2c096a4ea5af0 Mon Sep 17 00:00:00 2001 From: Christian Mortaro Date: Sun, 21 Aug 2022 13:55:30 -0300 Subject: [PATCH 105/105] :bug: skip cleaning non existing dir --- scripts/index.js | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/scripts/index.js b/scripts/index.js index db3e0361..34034d1f 100755 --- a/scripts/index.js +++ b/scripts/index.js @@ -48,6 +48,19 @@ 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; + }); + } + }); +} + async function start({ input, port, env, mode = 'spa', cold, disk }) { const environment = 'development' console.log(` πŸš€οΈ Starting your application in ${environment} mode...`); @@ -123,16 +136,7 @@ async function start({ input, port, env, mode = 'spa', cold, disk }) { port: process.env['NULLSTACK_SERVER_PORT'] }; const compiler = getCompiler({ environment, input, disk }); - const outputPath = compiler.compilers[0].outputPath; - 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; - }); - } - }); + clearOutput(compiler.compilers[0].outputPath) const server = new WebpackDevServer(devServerOptions, compiler); const portChecker = require('express')().listen(process.env['NULLSTACK_SERVER_PORT'], () => { portChecker.close()