Skip to content
15 changes: 9 additions & 6 deletions client/anchorableNode.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@ import router from './router'
export function anchorableElement(element) {
const links = element.querySelectorAll('a[href^="/"]:not([target])')
for (const link of links) {
link.onclick = (event) => {
if (event.ctrlKey || event.shiftKey) return
event.preventDefault()
router.url = link.getAttribute('href')
}
if (link.dataset.nullstack) return
link.dataset.nullstack = true
link.addEventListener('click', (event) => {
if (!event.ctrlKey && !event.shiftKey && !event.altKey && !event.metaKey) {
event.preventDefault()
router.url = link.getAttribute('href')
}
})
}
}
}
56 changes: 29 additions & 27 deletions client/render.js
Original file line number Diff line number Diff line change
@@ -1,62 +1,64 @@
import {isFalse, isText} from '../shared/nodes';
import {anchorableElement} from './anchorableNode';
import { isFalse, isText } from '../shared/nodes';
import { anchorableElement } from './anchorableNode';

export default function render(node, options) {

if(isFalse(node) || node.type === 'head') {
if (isFalse(node) || node.type === 'head') {
return document.createComment("");
}

if(isText(node)) {
if (isText(node)) {
return document.createTextNode(node);
}

const svg = (options && options.svg) || node.type === 'svg';

let element;
if(svg) {
if (svg) {
element = document.createElementNS("http://www.w3.org/2000/svg", node.type);
} else {
element = document.createElement(node.type);
}

if(node.instance) {
if (node.instance) {
node.instance._self.element = element;
}

for(let name in node.attributes) {
if(name === 'html') {
for (let name in node.attributes) {
if (name === 'html') {
element.innerHTML = node.attributes[name];
anchorableElement(element);
} else if(name.startsWith('on')) {
const eventName = name.replace('on', '');
const key = '_event.' + eventName;
node[key] = (event) => {
if(node.attributes.default !== true) {
event.preventDefault();
}
node.attributes[name]({...node.attributes, event});
};
element.addEventListener(eventName, node[key]);
} else if (name.startsWith('on')) {
if (node.attributes[name] !== undefined) {
const eventName = name.replace('on', '');
const key = '_event.' + eventName;
node[key] = (event) => {
if (node.attributes.default !== true) {
event.preventDefault();
}
node.attributes[name]({ ...node.attributes, event });
};
element.addEventListener(eventName, node[key]);
}
} else {
const type = typeof(node.attributes[name]);
if(type !== 'object' && type !== 'function') {
if(name != 'value' && node.attributes[name] === true) {
const type = typeof (node.attributes[name]);
if (type !== 'object' && type !== 'function') {
if (name != 'value' && node.attributes[name] === true) {
element.setAttribute(name, '');
} else if(name == 'value' || (node.attributes[name] !== false && node.attributes[name] !== null && node.attributes[name] !== undefined)) {
} else if (name == 'value' || (node.attributes[name] !== false && node.attributes[name] !== null && node.attributes[name] !== undefined)) {
element.setAttribute(name, node.attributes[name]);
}
}
}
}

if(!node.attributes.html) {
for(let i = 0; i < node.children.length; i++) {
const child = render(node.children[i], {svg});
if (!node.attributes.html) {
for (let i = 0; i < node.children.length; i++) {
const child = render(node.children[i], { svg });
element.appendChild(child);
}
if(node.type == 'select') {

if (node.type == 'select') {
element.value = node.attributes.value;
}
}
Expand Down
2 changes: 1 addition & 1 deletion client/rerender.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,8 @@ export default function rerender(selector, current, next) {
if (name === 'html') {
if (next.attributes[name] !== current.attributes[name]) {
selector.innerHTML = next.attributes[name];
anchorableElement(selector);
}
anchorableElement(selector);
} else if (name === 'checked') {
if (next.attributes[name] !== selector.value) {
selector.checked = next.attributes[name];
Expand Down
9 changes: 5 additions & 4 deletions plugins/anchorable.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@ function transform({ node, router }) {
const originalEvent = node.attributes.onclick
node.attributes.default = true
node.attributes.onclick = ({ event }) => {
if (event.ctrlKey || event.shiftKey) return
event.preventDefault()
router.url = node.attributes.href
if (!event.ctrlKey && !event.shiftKey && !event.altKey && !event.metaKey) {
event.preventDefault()
router.url = node.attributes.href
}
if (originalEvent) {
setTimeout(() => {
originalEvent({ ...node.attributes, event })
Expand All @@ -24,4 +25,4 @@ function transform({ node, router }) {
}
}

export default { transform, client: true }
export default { transform, client: true }
18 changes: 15 additions & 3 deletions tests/src/AnchorModifiers.njs
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,23 @@ class AnchorModifiers extends Nullstack {
<a href="/anchor-modifiers?source=html">html</a>
`

render() {
hydrate(context) {
context.self.element.querySelector('a').addEventListener('click', () => {
context.clickedHTML = true
})
}

clickJSX(context) {
context.clickedJSX = true
}

render({ clickedJSX, clickedHTML }) {
return (
<div>
<div data-clicked-jsx={clickedJSX} data-clicked-html={clickedHTML}>
<div html={this.html} />
<a href="/anchor-modifiers?source=jsx">jsx</a>
<a href="/anchor-modifiers?source=jsx" onclick={this.clickJSX}>
jsx
</a>
</div>
)
}
Expand Down
50 changes: 43 additions & 7 deletions tests/src/AnchorModifiers.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,19 @@ describe('AnchorModifiers jsx', () => {
expect(url).toEqual('http://localhost:6969/anchor-modifiers');
});

test('Clicking html link with control opens in new tab', async () => {
await page.keyboard.down('Control');
test('Clicking html link with control or meta opens in new tab', async () => {
const key = process.platform === 'darwin' ? 'Meta' : 'Control'
await page.keyboard.down(key);
await page.click('[href="/anchor-modifiers?source=html"]');
await page.keyboard.up('Control');
await page.keyboard.up(key);
const url = await page.url()
expect(url).toEqual('http://localhost:6969/anchor-modifiers');
});

test('Clicking html link with alt downloads the link', async () => {
await page.keyboard.down('Alt');
await page.click('[href="/anchor-modifiers?source=html"]');
await page.keyboard.up('Alt');
const url = await page.url()
expect(url).toEqual('http://localhost:6969/anchor-modifiers');
});
Expand All @@ -28,12 +37,39 @@ describe('AnchorModifiers jsx', () => {
expect(url).toEqual('http://localhost:6969/anchor-modifiers');
});

test('Clicking jsx link with control opens in new tab', async () => {
await page.keyboard.down('Control');
test('Clicking jsx link with control or meta opens in new tab', async () => {
const key = process.platform === 'darwin' ? 'Meta' : 'Control'
await page.keyboard.down(key);
await page.click('[href="/anchor-modifiers?source=jsx"]');
await page.keyboard.up('Control');
await page.keyboard.up(key);
const url = await page.url()
expect(url).toEqual('http://localhost:6969/anchor-modifiers');
});

});
test('Clicking jsx link with alt downloads the link', async () => {
await page.keyboard.down('Alt');
await page.click('[href="/anchor-modifiers?source=jsx"]');
await page.keyboard.up('Alt');
const url = await page.url()
expect(url).toEqual('http://localhost:6969/anchor-modifiers');
});

test('Clicking html link with modifier runs the original event', async () => {
await page.keyboard.down('Shift');
await page.click('[href="/anchor-modifiers?source=html"]');
await page.keyboard.up('Shift');
await page.waitForSelector('[data-clicked-html]');
const element = await page.$('[data-clicked-html]');
expect(element).toBeTruthy();
});

test('Clicking jsx link with modifier runs the original event', async () => {
await page.keyboard.down('Shift');
await page.click('[href="/anchor-modifiers?source=jsx"]');
await page.keyboard.up('Shift');
await page.waitForSelector('[data-clicked-jsx]');
const element = await page.$('[data-clicked-jsx]');
expect(element).toBeTruthy();
});

});
21 changes: 21 additions & 0 deletions tests/src/RoutesAndParams.njs
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,17 @@ class RoutesAndParams extends Nullstack {

eventTriggered = false;
paramHydrated = false;
shownLink = true;
htmlLinkBtn = '';

hydrate(context) {
const { router, params } = context;
this.paramHydrated = params.id === 'a';
window.addEventListener(router.event, () => {
context.eventTriggered = true;
});
this.shownLink = !params.hideLink;
this.htmlLinkBtn = 'click';
}

renderOther({ params }) {
Expand All @@ -29,6 +33,22 @@ class RoutesAndParams extends Nullstack {
)
}

renderInnerHTML({ params, route }) {
const html = `
<a href="${route}?hideLink=${params.hideLink}"> innerHTML </a>
`
return (
<div data-shown-link={this.shownLink}>
<button
onclick={{shownLink: true, htmlLinkBtn: 'clicked'}}
data-show-link={this.htmlLinkBtn}
html={this.htmlLinkBtn}
/>
<div html={this.shownLink ? html : ''} />
</div>
)
}

setParamsDate({ params }) {
params.date = new Date('1992-10-16');
}
Expand All @@ -46,6 +66,7 @@ class RoutesAndParams extends Nullstack {
<div data-event-triggered={eventTriggered} />
<div data-router={!!router} />
<div route="/routes-and-params" data-route="/routes-and-params" />
<InnerHTML route="/routes-and-params/inner-html" />
<Other route="/routes-and-params/:id" />
<Wildcard route="/routes-and-params/*" />
<a href="/routes-and-params/a"> a </a>
Expand Down
27 changes: 27 additions & 0 deletions tests/src/RoutesAndParams.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -229,4 +229,31 @@ describe('RoutesAndParams /routes-and-params?previous=true', () => {
expect(element).toBeTruthy();
});

});

describe('RoutesAndParams /routes-and-params/inner-html', () => {

const htmlRoute = 'routes-and-params/inner-html?hideLink=';

async function redirectAndKeepState(hiddenLink = '') {
await page.goto(`http://localhost:6969/${htmlRoute}${hiddenLink}`);
await page.waitForSelector('[data-show-link="click"]');
await page.click('[data-show-link="click"]');
await page.waitForSelector('[data-show-link="clicked"]');
await page.waitForSelector('[data-shown-link]');
await page.waitForSelector(`[href="/${htmlRoute}${hiddenLink}"]`);
await page.click(`[href="/${htmlRoute}${hiddenLink}"]`);
return page.$('[data-show-link="clicked"]');
}

test('html route injected from start do not refresh', async () => {
const element = await redirectAndKeepState();
expect(element).toBeTruthy();
});

test('html route injected after first render do not refresh', async () => {
const element = await redirectAndKeepState('true');
expect(element).toBeTruthy();
});

});
19 changes: 11 additions & 8 deletions tests/src/StatefulComponent.njs
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,27 @@ import Nullstack from 'nullstack';
class StatefulComponent extends Nullstack {

count = 1;
object = {count: 0};
object = { count: 0 };
prepared = 0;
date = new Date('1992-10-16');
empty = '';
visible = false;

prepare() {
this.prepared++;
}

increment({by}) {
increment({ by }) {
this.count += by;
}

incrementByOne() {
this.count++;
}
render({self}) {

render({ self }) {
return (
<form>
<form>
{self.hydrated &&
<div data-tag={self.element.tagName.toLowerCase()} />
}
Expand All @@ -32,20 +33,22 @@ class StatefulComponent extends Nullstack {
<button class="increment-by-two" onclick={this.increment} by={2}>
+2
</button>
<button class="set-to-one" onclick={{count: 1}}>
<button class="set-to-one" onclick={{ count: 1 }}>
=1
</button>
<button class="set-object-to-one" source={this.object} onclick={{count: 1}}>
<button class="set-object-to-one" source={this.object} onclick={{ count: 1 }}>
=1
</button>
<p data-empty={this.empty}>{this.empty}</p>
<button onclick={{empty: 'not'}} data-fill> fill </button>
<button onclick={{ empty: 'not' }} data-fill> fill </button>
<>
<div data-prepared={this.prepared} />
<div data-count={this.count} />
<div data-object-count={this.object.count} />
<div data-year={this.date.getFullYear()} />
</>
<button onclick={{ visible: !this.visible }} data-toggle> Toggle </button>
{this.visible && <button onclick={undefined} data-undefined-event> button </button>}
</form>
)
}
Expand Down
10 changes: 10 additions & 0 deletions tests/src/StatefulComponent.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,14 @@ describe('StatefulComponent', () => {
expect(text).toMatch('not');
});

test('rendered attributes undefined values do not raise errors', async () => {
await page.click('[data-toggle]');
await page.waitForSelector('[data-undefined-event]');
await page.click('[data-undefined-event]');
let hasConsoleError = false
page.on("console", () => hasConsoleError = true)
await page.waitForTimeout(2000)
expect(hasConsoleError).toBeFalsy();
});

});