Skip to content

Commit

Permalink
Add children parsing algo
Browse files Browse the repository at this point in the history
  • Loading branch information
dy committed Mar 31, 2020
1 parent 5012e25 commit 7ad8f94
Show file tree
Hide file tree
Showing 2 changed files with 179 additions and 104 deletions.
242 changes: 143 additions & 99 deletions $.js
@@ -1,59 +1,22 @@
import * as symbol from './symbols.js'
import channel from './channel.js'

const SPECT_CLASS = '👁'
const ELEMENT = 1

const SPECT_CLASS = '👁'
const CLASS_OFFSET = 0x1F700
let count = 0
const _spect = Symbol.for('@spect')

const _spect = Symbol.for('@spect'), _observer = Symbol.for('@spect.observer')

const ids = {}, classes = {}, tags = {}, names = {}, animations = {}

const style = document.head.appendChild(document.createElement('style'))
style.classList.add('spect-style')
const { sheet } = style

const observer = new MutationObserver((list) => {
for (let mutation of list) {
let { addedNodes, removedNodes, target } = mutation
if (mutation.type === 'childList') {
removedNodes.forEach(target => {
if (target.nodeType !== ELEMENT) return
;[target.classList.contains(SPECT_CLASS) ? target : null, ...target.getElementsByClassName(SPECT_CLASS)]
.forEach(node => node && node[_spect].forEach(set => set.delete(node)))
})

addedNodes.forEach(target => {
if (target.nodeType !== ELEMENT) return

// selector-set optimization:
// instead of walking full ruleset for each node, we detect which rules are applicable for the node
// ruleset checks target itself and its children, returns list of [el, aspect] tuples
if (target.id) {
ids[target.id] && ids[target.id].forEach(c => c.add(target))
let node
for (let id in ids) if (node = target.getElementById(id)) ids[id].forEach(c => c.add(node))
}
if (target.className) {
target.classList.forEach(cls => classes[cls] && classes[cls].forEach(c => c.add(target)))
for (let cls in classes) [].forEach.call(target.getElementsByClassName(cls), node => classes[cls].forEach(c => c.add(node)))
}
if (target.attributes.name) {
const name = target.attributes.name.value
names[name] && names[name].forEach(c => c.add(target))
for (let name in names) [].forEach.call(target.getElementsByName(name), node => names[name].forEach(c => c.add(node)))
}
tags[target.tagName] && tags[target.tagName].forEach(c => c.add(target))
for (let tag in tags) [].forEach.call(target.getElementsByTagName(tag), node => tags[tag].forEach(c => c.add(node)))
})
}
}
})
observer.observe(document, {
childList: true,
subtree: true
})


export default function spect(scope, selector, fn) {
export default function (scope, selector, fn) {
// spect`#x`
if (scope && scope.raw) return new $(null, String.raw(...arguments))
// spect(selector, fn)
Expand All @@ -73,59 +36,81 @@ export default function spect(scope, selector, fn) {
return new $(scope, selector, fn)
}

export class $ extends Array {

class $ extends Array {
constructor(scope, selector, fn){
// self-call, like splice, map, slice etc. fall back to array
if (typeof scope === 'number') return Array(scope)

super()

if (scope) this._scope = scope
if (selector) this._selector = selector
if (fn) this._fn = fn
this._match
this._id
this._tag
this._name
this._class
this._channel = channel()
this._items = new WeakMap
this._items = new WeakSet
this._delete = new WeakSet
this._teardown = new WeakMap
if (scope) this._scope = scope
if (fn) this._fn = fn

// ignore non-selector collections
if (!selector) return

this._selector = selector

// init existing elements
this.add(...(scope || document).querySelectorAll(selector))

const parts = selector && selector.match(/^(\s*)(?:#([\w-]+)|(\w+)|\.([\w-]+)|\[\s*name=([\w]+)\s*\])([^]*)/)
const selectors = selector.split(/\s*,\s*/)

const rpart = /^\s*(?:#([\w-]+)|\[\s*name=([\w-]+)\s*\]|\.([\w-]+)|(\w+))/
const rmatch = /^(?:\[[^\]]+\]|[^\s>+~\[]+)+/

let complex = false
this._parts = selectors.map(selector => {
let parts = [], match

while (selector && (match = selector.match(rpart))) {
const [chunk, ...part] = match
selector = selector.slice(chunk.length)
if (part[3]) part[3] = part[3].toUpperCase()

// parse filters
if (match = selector.match(rmatch)) {
part.push(match[0])
selector = selector.slice(match[0].length)
}

if (parts) {
// TODO: handle multiple simple parts, make sure rulesets don't overlap
let [, op, id, tag, cls, name, match ] = parts
parts.push(part)
}

this._match = match
// if exited with non-empty string - selector is not trivial, delegate to anim events
if (selector) return complex = true

// indexable selectors
if (id) (ids[id] = ids[this._id = id] || []).push(this)
else if (name) (names[name] = names[this._name = name] || []).push(this)
else if (cls) (classes[cls] = classes[this._class = cls] || []).push(this)
else if (tag) (this._tag = tag = tag.toUpperCase(), tags[tag] = tags[tag] || []).push(this)
}
const [id, name, cls, tag, filter] = parts[0]
if (id) (ids[id] = ids[id] || []).push(this)
else if (name) (names[name] = names[name] || []).push(this)
else if (cls) (classes[cls] = classes[cls] || []).push(this)
else if (tag) (tag, tags[tag] = tags[tag] || []).push(this)

// elements can't be turned to match tag selectors without remove/add, so anim observer is not required
if (parts.some(([i,n,c, tag, filter]) => !tag || filter)) complex = true

return parts
})


// complex selectors are handled via anim events (technique from insertionQuery). Cases:
// - dynamically added attributes so that existing nodes match (we don't observe attribs in mutation obserever)
// - complex selectors, inc * - we avoid >O(c) sync mutations check
// Simple tag selectors are meaningless to observe - they're never going to dynamically match.
// - complex selectors, inc * - we avoid > O(c) sync mutations check
// NOTE: only connected scope supports anim observer
// FIXME: if complex selectors have `animation`redefined by user-styles it may conflict
if (!/^\w+$/.test(selector)) {
if (complex) {
let anim = animations[selector]
if (!anim) {
anim = animations[selector] = []
this._animation = anim.id = String.fromCodePoint(CLASS_OFFSET + count++)
sheet.insertRule(`@keyframes ${ anim.id }{}`, sheet.rules.length)
sheet.insertRule(`${ selector }:not(.${ anim.id }){animation:${ anim.id }}`, sheet.rules.length)
sheet.insertRule(`${ selectors.map(sel => sel + `:not(.${ anim.id })`) }{animation:${ anim.id }}`, sheet.rules.length)
sheet.insertRule(`.${ anim.id }{animation:${ anim.id }}`, sheet.rules.length)
sheet.insertRule(`${ selector }.${ anim.id }{animation:unset;animation:revert}`, sheet.rules.length)
sheet.insertRule(`${ selectors.map(sel => sel + `.${ anim.id }`) }{animation:unset;animation:revert}`, sheet.rules.length)
anim.rules = [].slice.call(sheet.rules, -4)

anim.onanim = e => {
Expand Down Expand Up @@ -154,6 +139,14 @@ export class $ extends Array {
add(el, ...els) {
if (!el) return

// track collection
this.push(el)
this._items.add(el)
if (el.name) this[el.name] = el
if (el.id) this[el.id] = el
// cancel planned delete
if (this._delete.has(el)) this._delete.delete(el)

// ignore existing items
if (el[_spect] && el[_spect].has(this)) return

Expand All @@ -166,11 +159,20 @@ export class $ extends Array {
// ignore not-matching
if (this._match) if (!el.matches(this._match)) return


// expose refs
// TODO: add attribs mutation observer
if (el.attributes.name) this[el.attributes.name.value] = el
if (el.id) this[el.id] = el
// NOTE: this does not hook props added after
// if ((el.name || el.id)) {
// if (!el[_observer]) {
// let name = el.name, id = el.id
// // id/name mutation observer tracks refs and handles unmatch
// ;(el[_observer] = new MutationObserver(records => {
// if (name && (el.name !== name)) if (names[name]) names[name].forEach(c => (delete c[name], c.delete(el)))
// if (name = el.name) if (names[name]) names[name].forEach(c => c.add(el))
// if (id && (el.id !== id)) if (ids[id]) ids[id].forEach(c => (delete c[id], c.delete(el)))
// if (id = el.id) if (ids[id]) ids[id].forEach(c => c.add(el))
// }))
// .observe(el, {attributes: true, attributeFilter: ['name', 'id']})
// }
// }

// enable item
if (!el[_spect]) el[_spect] = new Set
Expand All @@ -179,33 +181,20 @@ export class $ extends Array {
el[_spect].add(this)
el.classList.add(SPECT_CLASS)

// cancel planned delete
if (this._delete.has(el)) this._delete.delete(el)

// track collection
this.push(el)
this._items.set(el, this._fn && this._fn(el))

// notify
this._teardown.set(el, this._fn && this._fn(el))
this._channel.push(this)

if (els.length) this.add(...els)
}

delete(el, immediate = false) {
// clean up refs
if (el.attributes.name) delete this[el.attributes.name.value]
if (el.id) delete this[el.id]

// remove element from list sync
const teardown = this._items.get(el)
this._items.delete(el)
if (this.length) {
this.splice(this.indexOf(el >>> 0, 1), 1)
this._channel.push(this)
}

// plan destructor async
if (this.length) this.splice(this.indexOf(el >>> 0, 1), 1)
if (el.name) delete this[el.name]
if (el.id) delete this[el.id]
// plan destroy async (can be re-added)
this._delete.add(el)

const del = () => {
Expand All @@ -214,16 +203,22 @@ export class $ extends Array {

if (!el[_spect]) return

const teardown = this._teardown.get(el)
if (teardown) {
if (teardown.call) teardown(el)
else if (teardown.then) teardown.then(fn => fn && fn.call && fn())
}
this._teardown.delete(el)
this._channel.push(this)

el[_spect].delete(this)

if (!el[_spect].size) {
delete el[_spect]
el.classList.remove(SPECT_CLASS)
if (el[_observer]) {
el[_observer].disconnect()
delete el[_observer]
}
}
}

Expand All @@ -250,10 +245,14 @@ export class $ extends Array {
has(item) { return this._items.has(item) }

[symbol.dispose]() {
if (this._id) ids[this._id].splice(ids[this._id].indexOf(this) >>> 0, 1)
if (this._class) classes[this._class].splice(classes[this._class].indexOf(this) >>> 0, 1)
if (this._tag) tags[this._tag].splice(tags[this._tag].indexOf(this) >>> 0, 1)
if (this._name) names[this._name].splice(names[this._name].indexOf(this) >>> 0, 1)
if (this._parts) {
this._parts.forEach(([sel, [id, name, cls, tag]]) => {
id && ids[id].splice(ids[id].indexOf(this) >>> 0, 1)
name && names[name].splice(names[name].indexOf(this) >>> 0, 1)
cls && classes[cls].splice(classes[cls].indexOf(this) >>> 0, 1)
tag && tags[tag].splice(tags[tag].indexOf(this) >>> 0, 1)
})
}
if (this._animation) {
const anim = animations[this._selector]
anim.splice(anim.indexOf(this) >>> 0, 1)
Expand All @@ -273,3 +272,48 @@ export class $ extends Array {
els.forEach(el => this.delete(el, true))
}
}


const observer = new MutationObserver((list) => {
for (let mutation of list) {
let { addedNodes, removedNodes, target } = mutation
if (mutation.type === 'childList') {
removedNodes.forEach(target => {
if (target.nodeType !== ELEMENT) return
;[target.classList.contains(SPECT_CLASS) ? target : null, ...target.getElementsByClassName(SPECT_CLASS)]
.forEach(node => node && node[_spect].forEach(set => set.delete(node)))
})

addedNodes.forEach(target => {
if (target.nodeType !== ELEMENT) return

// selector-set optimization:
// instead of walking full ruleset for each node, we detect which rules are applicable for the node
// ruleset checks target itself and its children, returns list of [el, aspect] tuples
if (target.id) {
// ids(target).map(el => )
ids[target.id] && ids[target.id].forEach(c => c.add(target))
let node
// NOTE: <a> and other inlines may not have `getElementById`
if (target.getElementById) for (let id in ids) if (node = target.getElementById(id)) ids[id].forEach(c => c.add(node))
}
if (target.className) {
target.classList.forEach(cls => classes[cls] && classes[cls].forEach(c => c.add(target)))
for (let cls in classes) [].forEach.call(target.getElementsByClassName(cls), node => classes[cls].forEach(c => c.add(node)))
}
if (target.attributes.name) {
const name = target.attributes.name.value
names[name] && names[name].forEach(c => c.add(target))
for (let name in names) [].forEach.call(target.getElementsByName(name), node => names[name].forEach(c => c.add(node)))
}
tags[target.tagName] && tags[target.tagName].forEach(c => c.add(target))
for (let tag in tags) [].forEach.call(target.getElementsByTagName(tag), node => tags[tag].forEach(c => c.add(node)))
})
}
}
})
observer.observe(document, {
childList: true,
subtree: true
})

41 changes: 36 additions & 5 deletions test/$.js
Expand Up @@ -487,14 +487,45 @@ t('$: v($)', async t => {
$l.add(x = document.createElement('div'))
t.is(log, [[], [x]])
})
t.todo('$: changed attribute name', async t => {
t('$: changed attribute name rewires refefence', async t => {
let el = document.body.appendChild(h`<div><a/><a/></div>`)
let x = $(el, '#a, #b')
el.childNodes[1].id = 'a'
await frame(2)
t.is([...x], [el.childNodes[1]])
t.is(x.a, el.childNodes[1])

console.log('set b')
el.childNodes[1].id = 'b'
await tick(2)
t.is([...x], [el.childNodes[1]])
t.is(x.a, undefined)
t.is(x.b, el.childNodes[1])

el.innerHTML = ''
await frame(2)
t.is([...x], [])
t.is(x.a, undefined)

x[Symbol.dispose]()
await frame(2)
})
t.skip('$: complex sync selector cases', async t => {
$('a#b c.d', el => {
log.push(el)
t.todo('$: comma-separated simple selectors are still simple')
t.todo('$: simple selector cases', async t => {
let log = []
// $('a#b c.d', el => {
// log.push(el)
// })
// document.body.append(...h`<a#b><c/><c.d/></a><a><c.d/></a>`)
// $('a b#c.d[name=e] f')

// $('a[name~="b"]')
})
t.skip('$: complex selectors', async t => {
$('a [x] b', el => {
})
document.body.appendChild(h`<a#b><c/><c.d/></a><a><c.d/></a>`)

$('a b > c')
})
t.demo('$: debugger cases', async t => {
console.log('*', $('*'))
Expand Down

0 comments on commit 7ad8f94

Please sign in to comment.