Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions client/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ client.selector = null
client.events = {}
client.generateContext = generateContext
client.renderQueue = null
client.currentBody = {}
client.nextBody = {}
client.currentMeta = { body: {}, html: {}, window: {} }
client.nextMeta = { body: {}, html: {}, window: {} }
client.currentHead = []
client.nextHead = []
client.head = document.head
Expand Down
21 changes: 14 additions & 7 deletions client/events.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,19 @@ export const eventCallbacks = new WeakMap()
export const eventSubjects = new WeakMap()
export const eventDebouncer = new WeakMap()

function executeEvent(callback, subject, event, data) {
if (typeof callback === 'object') {
Object.assign(subject.source, callback)
} else {
callback({ ...subject, event, data })
export function generateSubject(selector, attributes, name) {
if (Array.isArray(attributes[name])) {
for (let i = 0; i < attributes[name].length; i++) {
if (typeof attributes[name][i] === 'object') {
let changeset = attributes[name][i]
attributes[name][i] = () => Object.assign(attributes.source, changeset)
}
}
} else if (typeof attributes[name] === 'object') {
let changeset = attributes[name]
attributes[name] = () => Object.assign(attributes.source, changeset)
}
eventSubjects.set(selector, attributes)
}

function debounce(selector, name, time, callback) {
Expand Down Expand Up @@ -67,10 +74,10 @@ export function generateCallback(selector, name) {
if (subject[name] === noop) return
if (Array.isArray(subject[name])) {
for (const subcallback of subject[name]) {
executeEvent(subcallback, subject, event, data)
subcallback({ ...subject, event, data })
}
} else {
executeEvent(subject[name], subject, event, data)
subject[name]({ ...subject, event, data })
}
})
}
Expand Down
4 changes: 2 additions & 2 deletions client/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,9 @@ export default class Nullstack {
} else {
client.virtualDom = await generateTree(client.initializer(), scope)
hydrate(client.selector, client.virtualDom)
client.currentBody = client.nextBody
client.currentMeta = client.nextMeta
client.currentHead = client.nextHead
client.nextBody = {}
client.nextMeta = { body: {}, html: {}, window: {} }
client.nextHead = []
context.environment = environment
scope.plugins = loadPlugins(scope)
Expand Down
4 changes: 2 additions & 2 deletions client/render.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { sanitizeInnerHtml } from '../shared/sanitizeString'
import generateTruthyString from '../shared/generateTruthyString'
import { isFalse, isText } from '../shared/nodes'
import { anchorableElement } from './anchorableNode'
import { eventSubjects, generateCallback } from './events'
import { generateCallback, generateSubject } from './events'
import { ref } from './ref'

export default function render(node, options) {
Expand Down Expand Up @@ -36,7 +36,7 @@ export default function render(node, options) {
const eventName = name.substring(2)
const callback = generateCallback(node.element, name)
node.element.addEventListener(eventName, callback)
eventSubjects.set(node.element, node.attributes)
generateSubject(node.element, node.attributes, name)
}
} else {
let nodeValue
Expand Down
11 changes: 7 additions & 4 deletions client/rerender.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import generateTruthyString from '../shared/generateTruthyString'
import { isFalse, isText, isUndefined } from '../shared/nodes'
import { anchorableElement } from './anchorableNode'
import client from './client'
import { eventCallbacks, eventSubjects, generateCallback } from './events'
import { eventCallbacks, eventSubjects, generateCallback, generateSubject } from './events'
import { reref } from './ref'
import render from './render'

Expand Down Expand Up @@ -36,6 +36,7 @@ function updateAttributes(selector, currentAttributes, nextAttributes) {
if (!callback) {
selector.addEventListener(eventName, generateCallback(selector, name))
}
generateSubject(selector, nextAttributes, name)
eventSubjects.set(selector, nextAttributes)
}
}
Expand Down Expand Up @@ -156,12 +157,14 @@ function _rerender(current, next) {

export default function rerender() {
_rerender(client.virtualDom, client.nextVirtualDom)
updateAttributes(client.body, client.currentBody, client.nextBody)
updateAttributes(client.body, client.currentMeta.body, client.nextMeta.body)
updateAttributes(window, client.currentMeta.window, client.nextMeta.window)
updateAttributes(client.body.parentElement, client.currentMeta.html, client.nextMeta.html)
updateHeadChildren(client.currentHead, client.nextHead)
client.virtualDom = client.nextVirtualDom
client.nextVirtualDom = null
client.currentBody = client.nextBody
client.nextBody = {}
client.currentMeta = client.nextMeta
client.nextMeta = { body: {}, html: {}, window: {} }
client.currentHead = client.nextHead
client.nextHead = []
}
4 changes: 2 additions & 2 deletions server/prerender.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export async function prerender(request, response) {
scope.body = ''
scope.context = context
scope.generateContext = generateContext(context)
scope.nextBody = {}
scope.nextMeta = { body: {}, html: {}, window: {} }
scope.nextHead = []
scope.plugins = loadPlugins(scope)

Expand All @@ -48,7 +48,7 @@ export async function prerender(request, response) {
context.page.status = 500
} finally {
if (context.page.status !== 200) {
scope.nextBody = {}
scope.nextMeta = {body: {}, html: {}, window: {}}
scope.nextHead = []
for (const key in context.router._routes) {
delete context.router._routes[key]
Expand Down
6 changes: 3 additions & 3 deletions server/template.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import project from './project'
import renderAttributes from './renderAttributes'
import settings from './settings'

export default function ({ head, body, nextBody, context, instances }) {
export default function ({ head, body, nextMeta, context, instances }) {
const { page, router, worker, params } = context
const canonical = absolute(page.canonical || router.url)
const image = cdnOrAbsolute(page.image)
Expand Down Expand Up @@ -39,7 +39,7 @@ export default function ({ head, body, nextBody, context, instances }) {
context: environment.mode === 'spa' ? {} : serializableContext,
}
return `<!DOCTYPE html>
<html lang="${page.locale || ''}">
<html lang="${page.locale || ''}" ${renderAttributes(nextMeta.html)}>
<head>
<meta charset="utf-8">
<meta name="generator" content="Created with Nullstack - https://nullstack.app" />
Expand Down Expand Up @@ -75,7 +75,7 @@ export default function ({ head, body, nextBody, context, instances }) {
integrities['client.js'] || ''
}" defer crossorigin="anonymous"></script>
</head>
<body ${renderAttributes(nextBody)}>
<body ${renderAttributes(nextMeta.body)}>
${environment.mode === 'spa' ? '<div id="application"></div>' : body}
</body>
</html>`
Expand Down
35 changes: 22 additions & 13 deletions shared/generateTree.js
Original file line number Diff line number Diff line change
Expand Up @@ -105,27 +105,36 @@ async function generateBranch(siblings, node, depth, scope) {
return
}

if (node.type === 'body') {
if (node.type === 'body' || node.type === 'html' || node.type === 'window') {
const tagName = node.type
node.type = fragment
for (const attribute in node.attributes) {
if (attribute === 'children' || attribute.startsWith('_')) continue
if (attribute === 'class' || attribute === 'style') {
if (!scope.nextBody[attribute]) {
scope.nextBody[attribute] = []
}
scope.nextBody[attribute].push(node.attributes[attribute])
} else if (attribute.startsWith('on')) {
if (attribute.startsWith('on')) {
if (scope.context.environment.server) continue
if (!scope.nextBody[attribute]) {
scope.nextBody[attribute] = []
if (!scope.nextMeta[tagName][attribute]) {
scope.nextMeta[tagName][attribute] = []
}
if (Array.isArray(node.attributes[attribute])) {
scope.nextBody[attribute].push(...node.attributes[attribute])
for(const callback of node.attributes[attribute]) {
if (typeof callback === 'object') {
scope.nextMeta[tagName][attribute].push(() => Object.assign(node.attributes.source, callback))
} else {
scope.nextMeta[tagName][attribute].push(callback)
}
}
} else {
scope.nextBody[attribute].push(node.attributes[attribute])
scope.nextMeta[tagName][attribute].push(node.attributes[attribute])
}
} else if (tagName !== 'window') {
if (attribute === 'class' || attribute === 'style') {
if (!scope.nextMeta[tagName][attribute]) {
scope.nextMeta[tagName][attribute] = []
}
scope.nextMeta[tagName][attribute].push(node.attributes[attribute])
} else {
scope.nextMeta[tagName][attribute] = node.attributes[attribute]
}
} else {
scope.nextBody[attribute] = node.attributes[attribute]
}
}
}
Expand Down
8 changes: 7 additions & 1 deletion tests/src/Application.njs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import Nullstack from 'nullstack'
import AnchorModifiers from './AnchorModifiers'
import ArrayAttributes from './ArrayAttributes'
import BodyFragment from './BodyFragment'
import HtmlFragment from './HtmlFragment'
import WindowFragment from './WindowFragment'
import CatchError from './CatchError'
import ChildComponent from './ChildComponent'
import ComponentTernary from './ComponentTernary'
Expand Down Expand Up @@ -60,6 +62,7 @@ import LazyComponent from './LazyComponent'
import LazyComponentLoader from './LazyComponentLoader'
import NestedFolder from './nested/NestedFolder'
import ChildComponentWithoutServerFunctions from './ChildComponentWithoutServerFunctions'
import ObjectEventScope from './ObjectEventScope'
import './Application.css'

class Application extends Nullstack {
Expand Down Expand Up @@ -140,6 +143,8 @@ class Application extends Nullstack {
<DynamicHead route="/dynamic-head" />
<TextObserver route="/text-observer" />
<BodyFragment route="/body-fragment" />
<HtmlFragment route="/html-fragment" />
<WindowFragment route="/window-fragment" />
<ArrayAttributes route="/array-attributes" />
<RouteScroll route="/route-scroll/*" key="routeScroll" />
<IsomorphicImport route="/isomorphic-import" />
Expand All @@ -150,11 +155,12 @@ class Application extends Nullstack {
<NestedFolder route="/nested/folder" />
<LazyComponent route="/lazy-importer" prop="works" />
<ChildComponentWithoutServerFunctions route="/child-component-without-server-functions" />
<ObjectEventScope route="/object-event-scope" />
<ErrorPage route="*" />
</body>
)
}

}

export default Application
export default Application
57 changes: 57 additions & 0 deletions tests/src/HtmlFragment.njs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import Nullstack from 'nullstack'

class HtmlFragment extends Nullstack {

count = 0
visible = false
objected = false

increment() {
this.count++
}

reveal() {
this.visible = !this.visible
}

countDataKeys({ data }) {
this.hasDataKeys = Object.keys(data).length > 0
}

render() {
return (
<div data-html-parent>
<html
data-chars="a"
onclick={[this.increment, this.countDataKeys, { objected: true }]}
data-count={this.count}
class={['class-one', 'class-two', false]}
style="background-color: black;"
data-keys={this.hasDataKeys}
data-hydrated={this.hydrated}
>
<html
data-chars="b"
data-numbers="0"
data-count={this.count}
onclick={this.reveal}
class="class-three class-four"
style="color: white;"
data-objected={this.objected}
>
HtmlFragment
</html>
{this.visible && (
<html data-visible data-has-visible={this.hasDataVisible}>
HtmlFragment2
</html>
)}
<a href="/" data-html-child>home</a>
</html>
</div>
)
}

}

export default HtmlFragment
60 changes: 60 additions & 0 deletions tests/src/HtmlFragment.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
beforeEach(async () => {
await page.goto('http://localhost:6969/html-fragment')
})

describe('HtmlFragment', () => {
test('the html behaves as a fragment and creates no markup', async () => {
const element = await page.$('[data-html-parent] > [data-html-child]')
expect(element).toBeTruthy()
})

test('when the html is nested regular attributes are overwritten by the last one in the tree', async () => {
const element = await page.$('html[data-chars="b"]')
expect(element).toBeTruthy()
})

test('when the html is nested classes are merged togheter', async () => {
const element = await page.$('html[class="class-one class-two class-three class-four"]')
expect(element).toBeTruthy()
})

test('when the html is nested styles are merged togheter', async () => {
const element = await page.$('html[style="background-color: black; color: white;"]')
expect(element).toBeTruthy()
})

test('when the html is nested events are invoked sequentially', async () => {
await page.waitForSelector('html[data-hydrated]')
await page.click('html')
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 html is added to the vdom attributes are added', async () => {
await page.waitForSelector('html[data-hydrated]')
await page.click('html')
await page.waitForSelector('html[data-visible]')
const element = await page.$('html[data-visible]')
expect(element).toBeTruthy()
})

test('when a html is removed from the vdom attributes are removed', async () => {
await page.waitForSelector('html[data-hydrated]')
await page.click('html')
await page.waitForSelector('html[data-visible]')
await page.click('html')
await page.waitForSelector('html:not([data-visible])')
const element = await page.$('html:not([data-visible])')
expect(element).toBeTruthy()
})

test('the html removes events when the fragment leaves the tree', async () => {
await page.waitForSelector('html[data-hydrated]')
await page.click('[href="/"]')
await page.waitForSelector('[data-application-hydrated]:not([data-count])')
await page.click('html')
const element = await page.$('[data-application-hydrated]:not([data-count])')
expect(element).toBeTruthy()
})
})
Loading