diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..aee557f --- /dev/null +++ b/.editorconfig @@ -0,0 +1,7 @@ +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 2 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..376540e --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.DS_Store +npm-debug.log +lerna-debug.log +dist +build + +# We build custom links for the vulture dependencies in our monorepo, don’t +# ignore those. +**/node_modules/* +!packages/*/node_modules/vulture* diff --git a/assets/entries/vulture.js b/assets/entries/vulture.js new file mode 100644 index 0000000..e20cca7 --- /dev/null +++ b/assets/entries/vulture.js @@ -0,0 +1,4 @@ +// TODO: This file… + +export { jsx } from 'vulture/index' +export { render } from 'vulture-dom/index' diff --git a/assets/env-test.js b/assets/env-test.js new file mode 100644 index 0000000..0d531c7 --- /dev/null +++ b/assets/env-test.js @@ -0,0 +1,3 @@ +require('ts-node/register') + +global.Observable = require('rxjs').Observable diff --git a/assets/env.d.ts b/assets/env.d.ts new file mode 100644 index 0000000..f50884a --- /dev/null +++ b/assets/env.d.ts @@ -0,0 +1,39 @@ +// Source: https://github.com/zenparsing/es-observable + +declare class Observable { + constructor (subscriber: SubscriberFn) + + subscribe (observer: Observer): Subscription + subscribe ( + onNext: (value: T) => void, + onError?: (error: Error) => void, + onComplete?: (value?: any) => void + ): Subscription + + static of (...items: T[]): Observable + + static from (observable: Observable): Observable + static from (observable: Iterable): Iterable + static from (observable: any): Observable +} + +interface Observer { + start (subscription: Subscription): void + next (value: T): void + error (error: Error): void + complete (value?: any): void +} + +interface Subscription { + unsubscribe (): void + closed: boolean +} + +interface SubscriptionObserver { + next (value: T): void + error (error: Error): void + complete (value?: any): void + closed: boolean +} + +type SubscriberFn = (observer: SubscriptionObserver) => (() => void) | Subscription diff --git a/examples/counter-simple/index.html b/examples/counter-simple/index.html new file mode 100644 index 0000000..e9cd16b --- /dev/null +++ b/examples/counter-simple/index.html @@ -0,0 +1,50 @@ + + + + Vulture Example: Simple Counter + + + + +
+ + + diff --git a/examples/todomvc/package.json b/examples/todomvc/package.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/examples/todomvc/package.json @@ -0,0 +1 @@ +{} diff --git a/examples/todomvc/src/components/todoApp.jsx b/examples/todomvc/src/components/todoApp.jsx new file mode 100644 index 0000000..691f9cc --- /dev/null +++ b/examples/todomvc/src/components/todoApp.jsx @@ -0,0 +1,24 @@ +import * as todoHeader from './todoHeader' +import * as todoItem from './todoItem' +import * as todoFooter from './todoFooter' + +export const render = store => +
+
+
+ {todoHeader.render(store)} +
+
+
    + {store.filteredTodoIDs$.map(todoIDs => todoIDs.map(id => +
  • + {todoItem.render(store)(id)} +
  • + ))} +
+
+
+ {todoFooter.render(store)} +
+
+
diff --git a/examples/todomvc/src/components/todoFooter.jsx b/examples/todomvc/src/components/todoFooter.jsx new file mode 100644 index 0000000..98205e5 --- /dev/null +++ b/examples/todomvc/src/components/todoFooter.jsx @@ -0,0 +1,20 @@ +export const render = store => +
+

+ {store.incompleteTodosCount$} item{store.incompleteTodosCount$.map(n => n === 1 ? '' : 's')} left +

+ + {store.completedTodosCount$.map(n => + n > 0 + ? + : null + )} +
diff --git a/examples/todomvc/src/components/todoHeader.jsx b/examples/todomvc/src/components/todoHeader.jsx new file mode 100644 index 0000000..8ea3f01 --- /dev/null +++ b/examples/todomvc/src/components/todoHeader.jsx @@ -0,0 +1,26 @@ +export const render = store => +
+ {store.todoCount$.map(n => + n !== 0 + ?
diff --git a/examples/todomvc/src/components/todoItem.jsx b/examples/todomvc/src/components/todoItem.jsx new file mode 100644 index 0000000..f8f8994 --- /dev/null +++ b/examples/todomvc/src/components/todoItem.jsx @@ -0,0 +1,39 @@ +export const render = store => memoize(todoID => { + const editing$ = new Subject() + return ( +
+ {Observable.combineLatest( + store.getTodoText$(todoID), + editing$.startWith(false), + (text, editing) => + editing + ? renderInput(text, newText => { + if (newText) store.updateTodoText(todoID, newText) + editing$.next(false) + }) + :

editing$.next(true)}> + {text} +

+ )} +
+ ) +}) + +const renderInput = (initialText, finish) => + { + switch (event.keyCode) { + case ESCAPE_KEY: + finish() + break + case ENTER_KEY: + finish(event.target.value) + break + } + }} + /> diff --git a/examples/todomvc/src/model.js b/examples/todomvc/src/model.js new file mode 100644 index 0000000..797326f --- /dev/null +++ b/examples/todomvc/src/model.js @@ -0,0 +1,29 @@ +import * as selectors from './selectors' +import * as updates from './updates' + +const initialState = { + todoIDs: [], + todoByID: {}, + todoFilter: 'all', +} + +// function createStore () { +// const update$ = new Subject() +// const state$ = update$.scan((state, update) => update(state), initialState).cache(1) +// +// return { +// update$, +// state$, +// ...createSelectors(state$), +// ...bindUpdates(update$)(createUpdates()), +// } +// } + +const store = createStore( + initialState, + selectors, + updates +) + +// TODO: URL stuffs… +// store.todoFilter$.subscribe() diff --git a/examples/todomvc/src/selectors.js b/examples/todomvc/src/selectors.js new file mode 100644 index 0000000..5455741 --- /dev/null +++ b/examples/todomvc/src/selectors.js @@ -0,0 +1,64 @@ +const todoIDs$ = state$ => + state$.map(state => state.todoIDs).distinctUntilChanged() + +const todos$ = state$ => + state$.map(state => state.todoIDs.map(id => state.todoByID[id])).distinctUntilChanged(shallowArrayCompare) + +const todosWithIDs$ = state$ => + Observable.combineLatest( + todoIDs$(state$), + todos$(state$), + (ids, todos) => ids.map((id, i) => ({ + id, + todo: todos[i], + } + ))) + +const todoFilter$ = state$ => + state$.map(state => state.todoFilter).distinctUntilChanged() + +const todoFilterFunction$ = state$ => + todoFilter$(state$).map(filter => { + switch (filter) { + case 'all': return () => true + case 'active': return todo => !todo.completed + case 'completed': return todo => todo.completed + default: throw new Error(`Filter '${filter}' not supported.`) + } + }) + +export const filteredTodoIDs$ = state$ => + Observable.combineLatest( + todosWithIDs$(state$), + todoFilterFunction$(state$), + (todosWithIDs, todoFilterFunction) => + todosWithIDs.filter(({ todo }) => todoFilterFunction(todo)).map(({ id }) => id) + ) + +export const todoCount$ = state$ => + state$.map(state => state.todoIDs.length).distinctUntilChanged() + +export const completedTodosCount$ = state$ => + todos$(state$).map(todos => todos.filter(todo => todo.completed).length) + +export const incompleteTodosCount$ = state$ => + todos$(state$).map(todos => todos.filter(todo => !todo.completed).length) + +const getTodo$ = state$ => memoize(id => + state$.map(state => state.todoByID[id]).distinctUntilChanged() +) + +export const getTodoText$ = state$ => memoize(id => + getTodo$(id).map(todo => todo.text).distinctUntilChanged() +) + +function shallowArrayCompare (a, b) { + if (a.length !== b.length) + return false + + for (const i = 0; i < a.length; i++) + if (a[i] !== b[i]) + return false + + return true +} diff --git a/examples/todomvc/src/updates.js b/examples/todomvc/src/updates.js new file mode 100644 index 0000000..0bb0d43 --- /dev/null +++ b/examples/todomvc/src/updates.js @@ -0,0 +1,72 @@ +export const toggleTodoComplete = id => state => ({ + ...state, + todoByID: { + ...state.todoByID, + [id]: { + ...state.todoByID[id], + completed: !state.todoByID[id].completed, + }, + }, +}) + +export const updateTodoText = (id, text) => state => ({ + ...state, + todoByID: { + ...state.todoByID, + [id]: { + ...state.todoByID[id], + text, + }, + }, +}) + +export const deleteTodo = id => state => ({ + ...state, + todoIDs: state.todoIDs.filter(todoID => todoID !== id), + todoByID: { + ...state.todoByID, + [id]: undefined, + }, +}) + +export const setTodoFilter = todoFilter => state => ({ + ...state, + todoFilter, +}) + +export const clearCompleted = () => state => { + const isCompleted = todo => todo ? todo.completed : true + + const todoIDs = state.todoIDs.filter(id => !isCompleted(state.todoByID[id])) + + const lastTodoByID = state.todoByID + const todoByID = {} + + for (const id in lasttodoByID) + if (lastTodoByID.hasOwnProperty(id)) + if (!isCompleted(lasttodoByID[id])) + todoByID[id] = lasttodoByID[id] + + return { + ...state, + todoIDs, + todoByID, + } +} + +export const toggleAllTodosComplete = () => state => { + const lastTodoByID = state.todoByID + const todoByID = {} + + const allCompleted = state.todoByID.map(id => lastTodoByID[id]).all(todo => todo.completed) + const completed = !allCompleted + + for (const id in state.todoIDs) + if (lasttodoByID.hasOwnProperty(id)) + todoByID[id] = { ...lasttodoByID[id], completed } + + return { + ...state, + todoByID, + } +} diff --git a/examples/universal/src/components/githubApp.js b/examples/universal/src/components/githubApp.js new file mode 100644 index 0000000..06dcfc6 --- /dev/null +++ b/examples/universal/src/components/githubApp.js @@ -0,0 +1,13 @@ +import * as githubUser from './githubUser' +import * as githubRepos from './githubRepos' + +export const view = store => + + + Vulture Example: GitHub Viewer + + + {githubUser(store)} + {githubRepos(store)} + + diff --git a/examples/universal/src/components/githubRepo.js b/examples/universal/src/components/githubRepo.js new file mode 100644 index 0000000..e69de29 diff --git a/examples/universal/src/components/githubRepos.js b/examples/universal/src/components/githubRepos.js new file mode 100644 index 0000000..6ff6268 --- /dev/null +++ b/examples/universal/src/components/githubRepos.js @@ -0,0 +1,19 @@ +import * as githubRepo from './githubRepo' + +export const view = store => +
+ {} + {store.repos.map(repos => +
    + {...repos.map(repo => +
  • + {githubRepo.render(store)(repo)} +
  • + )} +
+ )} +
+ +export class Model { + +} diff --git a/examples/universal/src/components/githubUser.js b/examples/universal/src/components/githubUser.js new file mode 100644 index 0000000..e69de29 diff --git a/examples/universal/src/github.js b/examples/universal/src/github.js new file mode 100644 index 0000000..918056c --- /dev/null +++ b/examples/universal/src/github.js @@ -0,0 +1,38 @@ +const rehydrate$ = {} + +const username$ = + new Subject() + +const userRequest$ = + username$ + .filter(username => username != null) + +const userResponse$ = + userRequest$ + .switchMap(user => fetch(`https://api.github.com/users/${user}`)) + +const user$ = Observable.concat( + rehydrate$.map(({ user }) => user), + userResponse$ + .filter(response => response.ok) + .switchMap(response => response.json()), +) + +const reposRequest$ = + userRequest$ + +const reposResponse$ = + reposRequest$ + .switchMap(user => fetch(`https://api.github.com/users/${user}/repos`)) + +const repos$ = Observable.concat( + rehydrate$.map(({ repos }) => repos), + reposResponse$ + .filter(response => response.ok) + .switchMap(response => response.json()) +) + +const dehydrate$ = Observable.combineLatest( + user$, repos$, + (user, repos) => ({ user, repos }) +) diff --git a/examples/universal/src/server.js b/examples/universal/src/server.js new file mode 100644 index 0000000..0c58d1c --- /dev/null +++ b/examples/universal/src/server.js @@ -0,0 +1,7 @@ +const user$ = fetch(`https://api.github.com/users/${path.user}`) + +githubApp.render({ + userStore, + reposStore, + commentsStore, +}) diff --git a/gulpfile.js b/gulpfile.js new file mode 100644 index 0000000..b1b0f79 --- /dev/null +++ b/gulpfile.js @@ -0,0 +1,111 @@ +const fs = require('fs') +const gulp = require('gulp') +const glob = require('glob') +const rename = require('gulp-rename') +const sourcemaps = require('gulp-sourcemaps') +const clean = require('gulp-clean') +const header = require('gulp-header') +const ts = require('gulp-typescript') +const rollup = require('gulp-rollup') +const includePaths = require('rollup-plugin-includepaths') +const uglify = require('gulp-uglify') + +gulp.task('clean', () => + gulp.src('build') + .pipe(clean()) +) + +const tsResult = + gulp.src(['assets/env.d.ts', 'packages/*/src/**/*.ts']) + .pipe(sourcemaps.init()) + .pipe(ts(ts.createProject('tsconfig.json', { + typescript: require('typescript'), + noEmit: false, + }))) + +gulp.task('typings', () => + tsResult.dts + .pipe(rename(maybeRenameSourcePath)) + .pipe(gulp.dest('build/typings')) +) + +gulp.task('es6', () => + tsResult.js + .pipe(sourcemaps.write()) + .pipe(rename(maybeRenameSourcePath)) + .pipe(gulp.dest('build/es6')) +) + +gulp.task('es3', ['es6'], () => + gulp.src(['build/es6/**/*.js']) + .pipe(sourcemaps.init({ loadMaps: true })) + .pipe(ts({ + allowJs: true, + target: 'es3', + module: 'commonjs', + })) + .pipe(sourcemaps.write()) + .pipe(gulp.dest('build/es3')) +) + +const headerTemplate = +`/*! + * Vulture v<%= version %> + * (c) 2016 Caleb Meredith + * Released under the MIT License. + */ +` + +gulp.task('dist', ['es6'], () => + gulp.src(['assets/entries/*.js', 'build/es6/**/*.js']) + .pipe(sourcemaps.init({ loadMaps: true })) + .pipe(rollup({ + entry: glob.sync('assets/entries/*.js'), + format: 'iife', + moduleName: 'Vulture', + plugins: [ + includePaths({ paths: ['build/es6'] }), + ], + })) + .pipe(ts({ + allowJs: true, + target: 'es3', + })) + .pipe(header(headerTemplate, { + version: JSON.parse(fs.readFileSync('lerna.json', 'utf8')).version, + })) + .pipe(sourcemaps.write('.')) + .pipe(gulp.dest('build/dist')) +) + +gulp.task('dist-min', ['dist'], () => + gulp.src(['build/dist/+([^.]).js']) + .pipe(sourcemaps.init()) + .pipe(uglify({ preserveComments: 'license' })) + .pipe(sourcemaps.write('.')) + .pipe(rename(addMinToPath)) + .pipe(gulp.dest('build/dist')) +) + +gulp.task('bundle', ['typings', 'es6', 'es3'], () => + gulp.src(['build/{typings,es6,es3}/**/*']) + .pipe(rename(xxx)) + .pipe(gulp.dest('packages')) +) + +gulp.task('default', ['clean', 'typings', 'es6', 'es3', 'dist', 'dist-min']) + +function maybeRenameSourcePath (path) { + const sourcePathRe = /(vulture[^/]*)\/src(.*)$/ + + if (sourcePathRe.test(path.dirname)) { + const [, package, dirname] = sourcePathRe.exec(path.dirname) + path.dirname = `${package}${dirname}` + } +} + +function addMinToPath (path) { + const jsPathRe = /^(.*?)(\.js)?$/ + const [, basename, extension] = jsPathRe.exec(path.basename) + path.basename = `${basename}.min${extension || ''}` +} diff --git a/lerna.json b/lerna.json new file mode 100644 index 0000000..7d17caa --- /dev/null +++ b/lerna.json @@ -0,0 +1,4 @@ +{ + "lerna": "2.0.0-beta.13", + "version": "4.0.0-alpha.1" +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..07fa4d7 --- /dev/null +++ b/package.json @@ -0,0 +1,25 @@ +{ + "scripts": { + "check": "tsc", + "test": "ava **/__tests__/*.js -r ./assets/env-test", + "test:watch": "npm test -- -w" + }, + "devDependencies": { + "ava": "^0.15.2", + "glob": "^7.0.5", + "gulp": "^3.9.1", + "gulp-clean": "^0.3.2", + "gulp-header": "^1.8.7", + "gulp-rename": "^1.2.2", + "gulp-rollup": "^2.1.0", + "gulp-sourcemaps": "^1.6.0", + "gulp-typescript": "^2.13.6", + "gulp-uglify": "^1.5.4", + "lerna": "2.0.0-beta.13", + "rollup-plugin-includepaths": "^0.1.5", + "rxjs": "^5.0.0-beta.9", + "test-dom": "^1.0.0", + "ts-node": "^0.9.1", + "typescript": "^1.9.0-dev.20160625-1.0" + } +} diff --git a/packages/vulture-dom/node_modules/vulture/index.d.ts b/packages/vulture-dom/node_modules/vulture/index.d.ts new file mode 100644 index 0000000..70aa944 --- /dev/null +++ b/packages/vulture-dom/node_modules/vulture/index.d.ts @@ -0,0 +1 @@ +export * from '../../../vulture/src' diff --git a/packages/vulture-dom/node_modules/vulture/index.js b/packages/vulture-dom/node_modules/vulture/index.js new file mode 100644 index 0000000..1e337a6 --- /dev/null +++ b/packages/vulture-dom/node_modules/vulture/index.js @@ -0,0 +1 @@ +module.exports = require('../../../vulture/src') diff --git a/packages/vulture-dom/package.json b/packages/vulture-dom/package.json new file mode 100644 index 0000000..cb5de3c --- /dev/null +++ b/packages/vulture-dom/package.json @@ -0,0 +1,7 @@ +{ + "name": "vulture-dom", + "version": "4.0.0-alpha.0", + "dependencies": { + "vulture": "4.0.0-alpha.0" + } +} diff --git a/packages/vulture-dom/src/index.ts b/packages/vulture-dom/src/index.ts new file mode 100644 index 0000000..b995c14 --- /dev/null +++ b/packages/vulture-dom/src/index.ts @@ -0,0 +1,30 @@ +import { JSXAsync, diffAsync } from 'vulture' +import * as renderer from './renderer' + +export function render (jsx: JSXAsync, container: Node) { + if (container.childNodes.length !== 0) + throw new Error('Container is not empty.') + + const initialJSX = null + + const nodes = new Observable(observer => { + let lastNode: Node = renderer.renderNode(initialJSX) + container.appendChild(lastNode) + return diffAsync(jsx, initialJSX).subscribe( + patches => { + let nextNode = patches.reduce((node, patch) => renderer.reduceNode(node, patch), lastNode) + lastNode = nextNode + }, + error => observer.error(error), + () => observer.complete() + ) + }) + + return nodes.subscribe( + node => { + if (container.firstChild !== node) + container.replaceChild(node, container.firstChild) + }, + error => { throw error } + ) +} diff --git a/packages/vulture-dom/src/renderer/README.md b/packages/vulture-dom/src/renderer/README.md new file mode 100644 index 0000000..3afc47b --- /dev/null +++ b/packages/vulture-dom/src/renderer/README.md @@ -0,0 +1,2 @@ +# Vulture DOM Renderer +TODO diff --git a/packages/vulture-dom/src/renderer/__tests__/attributes.js b/packages/vulture-dom/src/renderer/__tests__/attributes.js new file mode 100644 index 0000000..d533c7e --- /dev/null +++ b/packages/vulture-dom/src/renderer/__tests__/attributes.js @@ -0,0 +1,43 @@ +import 'test-dom' + +import test from 'ava' +import { setAttribute, removeAttribute } from '../attributes' + +test('setAttribute will set numbers and strings', t => { + const element = document.createElement('div') + setAttribute(element, 'a', 'hello') + setAttribute(element, 'b', 42) + t.is(element.getAttribute('a'), 'hello') + t.is(element.getAttribute('b'), '42') +}) + +test('setAttribute will ignore undefined, null, and false', t => { + const element = document.createElement('div') + setAttribute(element, 'a', undefined) + setAttribute(element, 'b', null) + setAttribute(element, 'c', false) + t.false(element.hasAttribute('a')) + t.false(element.hasAttribute('b')) + t.false(element.hasAttribute('c')) +}) + +test('setAttribute will assign event handlers to the element', t => { + const element = document.createElement('div') + setAttribute(element, 'onClick', 'hello') + t.false(element.hasAttribute('onClick')) + t.is(element['onclick'], 'hello') +}) + +test('removeAttribute will remove attributes', t => { + const element = document.createElement('div') + element.setAttribute('a', '1') + element.setAttribute('b', '2') + element.setAttribute('c', '3') + removeAttribute(element, 'a') + removeAttribute(element, 'c') + t.false(element.hasAttribute('a')) + t.true(element.hasAttribute('b')) + t.false(element.hasAttribute('c')) +}) + +test.todo('removeAttribute will remove event handlers') diff --git a/packages/vulture-dom/src/renderer/__tests__/path.js b/packages/vulture-dom/src/renderer/__tests__/path.js new file mode 100644 index 0000000..077b096 --- /dev/null +++ b/packages/vulture-dom/src/renderer/__tests__/path.js @@ -0,0 +1,54 @@ +import 'test-dom' + +import test from 'ava' +import { $$key, lookupPath } from '../path' + +test('lookupPath will return the node for an empty path', t => { + const node = document.createTextNode('hello') + t.is(lookupPath(node, []), node) +}) + +test('lookupPath will find a node using a single step path', t => { + const a = document.createElement('a') + const b = document.createElement('b') + const c = document.createElement('c') + const d = document.createTextNode('d') + a[$$key] = 'a' + b[$$key] = 'b' + c[$$key] = 'c' + d[$$key] = 'd' + a.appendChild(b) + a.appendChild(c) + a.appendChild(d) + t.is(lookupPath(a, ['b']), b) + t.is(lookupPath(a, ['c']), c) + t.is(lookupPath(a, ['d']), d) +}) + +test('lookupPath will find a node using a two step path', t => { + const a = document.createElement('a') + const b = document.createElement('b') + const c = document.createElement('c') + a[$$key] = 'a' + b[$$key] = 'b' + c[$$key] = 'c' + a.appendChild(b) + b.appendChild(c) + t.is(lookupPath(a, ['b']), b) + t.is(lookupPath(a, ['b', 'c']), c) +}) + +test('lookupPath will return falsy when not found', t => { + const a = document.createElement('a') + const b = document.createElement('b') + const c = document.createElement('c') + a[$$key] = 'a' + b[$$key] = 'b' + c[$$key] = 'c' + a.appendChild(b) + b.appendChild(c) + t.falsy(lookupPath(a, ['b', 'c', 'd', 'e'])) + t.falsy(lookupPath(a, ['b', 'c', 'd'])) + t.falsy(lookupPath(a, ['b', 'd'])) + t.falsy(lookupPath(a, ['c'])) +}) diff --git a/packages/vulture-dom/src/renderer/__tests__/reducer.js b/packages/vulture-dom/src/renderer/__tests__/reducer.js new file mode 100644 index 0000000..5645b2d --- /dev/null +++ b/packages/vulture-dom/src/renderer/__tests__/reducer.js @@ -0,0 +1,149 @@ +import 'test-dom' + +import test from 'ava' +import { $$key } from '../path' +import { reduceNode } from '../reducer' + +test('reduceNode will fail if the path does not select an actual node', t => { + const a = document.createElement('a') + t.throws(() => reduceNode(a, { path: ['b'] })) + t.throws(() => reduceNode(a, { path: ['b', 'c'] })) +}) + +test('reduceNode will replace nodes', t => { + const a = document.createElement('a') + const b = document.createElement('b') + b[$$key] = 'b' + a.appendChild(b) + t.is(a.outerHTML, '') + reduceNode(a, { type: 'NODE_REPLACE', path: ['b'], node: 42 }) + t.is(a.outerHTML, '42') +}) + +test('reduceNode will replace nodes and set the correct key', t => { + const a = document.createElement('a') + const b = document.createElement('b') + b[$$key] = 'b' + a.appendChild(b) + t.is(a.firstChild, b) + t.is(a.firstChild[$$key], 'b') + reduceNode(a, { type: 'NODE_REPLACE', path: ['b'], node: 42 }) + t.not(a.firstChild, b) + t.is(a.firstChild[$$key], 'b') +}) + +test('reduceNode will actually replace the root node', t => { + const parent = document.createElement('parent') + const a = document.createElement('a') + const b = document.createElement('b') + b[$$key] = 'b' + a.appendChild(b) + parent.appendChild(a) + t.is(reduceNode(a, { type: 'NODE_REPLACE', path: ['b'], node: 42 }), a) + t.not(reduceNode(a, { type: 'NODE_REPLACE', path: [], node: 42 }), a) +}) + +test('reduceNode will set attributes', t => { + const a = document.createElement('a') + const b = document.createElement('b') + b[$$key] = 'b' + a.appendChild(b) + reduceNode(a, { type: 'ATTRIBUTE_SET', path: [], name: 'foo', value: 1 }) + reduceNode(a, { type: 'ATTRIBUTE_SET', path: ['b'], name: 'bar', value: 2 }) + t.is(a.getAttribute('foo'), '1') + t.is(b.getAttribute('bar'), '2') +}) + +test('reduceNode will not set attributes for non-elements', t => { + const a = document.createElement('a') + const b = document.createTextNode('b') + b[$$key] = 'b' + a.appendChild(b) + t.throws(() => reduceNode(a, { type: 'ATTRIBUTE_SET', path: ['b'], name: 'foo', value: 1 })) +}) + +test('reduceNode will remove attributes', t => { + const a = document.createElement('a') + const b = document.createElement('b') + a.setAttribute('foo', 1) + b.setAttribute('bar', 2) + b[$$key] = 'b' + a.appendChild(b) + t.true(a.hasAttribute('foo')) + t.true(b.hasAttribute('bar')) + reduceNode(a, { type: 'ATTRIBUTE_REMOVE', path: [], name: 'foo' }) + reduceNode(a, { type: 'ATTRIBUTE_REMOVE', path: ['b'], name: 'bar' }) + t.false(a.hasAttribute('foo')) + t.false(b.hasAttribute('bar')) +}) + +test('reduceNode will not remove attributes for non-elements', t => { + const a = document.createElement('a') + const b = document.createTextNode('b') + b[$$key] = 'b' + a.appendChild(b) + t.throws(() => reduceNode(a, { type: 'ATTRIBUTE_REMOVE', path: ['b'], name: 'foo' })) +}) + +test('reduceNode will add JSX children', t => { + const a = document.createElement('a') + const b = document.createElement('b') + b[$$key] = 'b' + a.appendChild(b) + reduceNode(a, { type: 'CHILDREN_ADD', path: [], key: 'c', node: 'c' }) + t.is(a.outerHTML, 'c') + reduceNode(a, { type: 'CHILDREN_ADD', path: ['b'], key: 'd', node: 'd' }) + t.is(a.outerHTML, 'dc') +}) + +test('reduceNode will remove JSX children', t => { + const a = document.createElement('a') + const b = document.createElement('b') + const c = document.createElement('c') + b[$$key] = 'b' + c[$$key] = 'c' + a.appendChild(b) + b.appendChild(c) + t.is(a.outerHTML, '') + reduceNode(a, { type: 'CHILDREN_REMOVE', path: ['b'], key: 'c' }) + t.is(a.outerHTML, '') + reduceNode(a, { type: 'CHILDREN_REMOVE', path: [], key: 'b' }) + t.is(a.outerHTML, '') +}) + +test('reduceNode will not remove JSX children that can’t be reached', t => { + const a = document.createElement('a') + const b = document.createElement('b') + b[$$key] = 'b' + a.appendChild(b) + t.throws(() => reduceNode(a, { type: 'CHILDREN_REMOVE', path: ['b'], key: 'c' })) + t.throws(() => reduceNode(a, { type: 'CHILDREN_REMOVE', path: [], key: 'c' })) +}) + +test('reduceNode will correctly reorder child nodes', t => { + const parent = document.createElement('div') + const a = document.createTextNode('1') + const b = document.createTextNode('2') + const c = document.createTextNode('3') + const d = document.createTextNode('4') + const e = document.createTextNode('5') + a[$$key] = 'a' + b[$$key] = 'b' + c[$$key] = 'c' + d[$$key] = 'd' + e[$$key] = 'e' + parent.appendChild(a) + parent.appendChild(b) + parent.appendChild(c) + parent.appendChild(d) + parent.appendChild(e) + t.is(parent.innerHTML, '12345') + reduceNode(parent, { type: 'CHILDREN_ORDER', path: [], keys: ['e', 'd', 'c', 'b', 'a'] }) + t.is(parent.innerHTML, '54321') + reduceNode(parent, { type: 'CHILDREN_ORDER', path: [], keys: ['a', 'b', 'c', 'd', 'e'] }) + t.is(parent.innerHTML, '12345') + reduceNode(parent, { type: 'CHILDREN_ORDER', path: [], keys: ['e', 'b', 'c', 'd', 'a'] }) + t.is(parent.innerHTML, '52341') + reduceNode(parent, { type: 'CHILDREN_ORDER', path: [], keys: ['b', 'd', 'a', 'e', 'c'] }) + t.is(parent.innerHTML, '24153') +}) diff --git a/packages/vulture-dom/src/renderer/__tests__/renderer.js b/packages/vulture-dom/src/renderer/__tests__/renderer.js new file mode 100644 index 0000000..c05c999 --- /dev/null +++ b/packages/vulture-dom/src/renderer/__tests__/renderer.js @@ -0,0 +1,37 @@ +import 'test-dom' + +import test from 'ava' +import { renderNode } from '../renderer' + +test('renderNode will render text elements for primitive nodes', t => { + t.is(renderNode(undefined).nodeValue, '') + t.is(renderNode(null).nodeValue, '') + t.is(renderNode(true).nodeValue, '') + t.is(renderNode(false).nodeValue, '') + t.is(renderNode(42).nodeValue, '42') + t.is(renderNode('hello').nodeValue, 'hello') +}) + +test('renderNode will render an element with an element name', t => { + t.is(renderNode({ elementName: 'div' }).tagName.toLowerCase(), 'div') + t.is(renderNode({ elementName: 'h1' }).tagName.toLowerCase(), 'h1') +}) + +test('renderNode will render an element with some attributes', t => { + const element = renderNode({ elementName: 'div', attributes: { a: 'hello', b: 42 } }) + t.is(element.getAttribute('a'), 'hello') + t.is(element.getAttribute('b'), '42') +}) + +test('renderNode with null, undefined, or false attributes will not exist', t => { + const element = renderNode({ elementName: 'div', attributes: { a: undefined, b: null, c: false } }) + t.false(element.hasAttribute('a')) + t.false(element.hasAttribute('b')) + t.false(element.hasAttribute('c')) +}) + +test('renderNode with true will set an attribute with an empty string', t => { + const element = renderNode({ elementName: 'div', attributes: { a: true } }) + t.true(element.hasAttribute('a')) + t.is(element.getAttribute('a'), '') +}) diff --git a/packages/vulture-dom/src/renderer/attributes.ts b/packages/vulture-dom/src/renderer/attributes.ts new file mode 100644 index 0000000..9d1c620 --- /dev/null +++ b/packages/vulture-dom/src/renderer/attributes.ts @@ -0,0 +1,25 @@ +export function setAttribute (element: Element, name: string, value: any) { + // If the name starts with 'on', it’s an event handler. Attach the event + // handler to the element. + if (name.startsWith('on')) + element[name.toLowerCase()] = value + // If the value is undefined, null, or false we want no attributes at all. An + // empty string is equivalent to `true` in some cases. + else if (value === undefined || value === null || value === false) + element.removeAttribute(name) + // Otherwise, set the attribute. + else + // If the value is true, we just want an empty string. An empty string is + // enough to represent true for many attributes like `autofocus`. + element.setAttribute(name, value === true ? '' : String(value)) +} + +export function removeAttribute (element: Element, name: string) { + // If the name starts with 'on', it’s an event handler. Remove the event + // handler from the element. + if (name.startsWith('on')) + element[name.toLowerCase()] = null + // Otherwise, remove the attribute + else + element.removeAttribute(name) +} diff --git a/packages/vulture-dom/src/renderer/index.ts b/packages/vulture-dom/src/renderer/index.ts new file mode 100644 index 0000000..e33830d --- /dev/null +++ b/packages/vulture-dom/src/renderer/index.ts @@ -0,0 +1,2 @@ +export { renderNode } from './renderer' +export { reduceNode } from './reducer' diff --git a/packages/vulture-dom/src/renderer/path.ts b/packages/vulture-dom/src/renderer/path.ts new file mode 100644 index 0000000..b6dbb86 --- /dev/null +++ b/packages/vulture-dom/src/renderer/path.ts @@ -0,0 +1,19 @@ +import { Path } from 'vulture' + +export const $$key = Symbol('vulture-dom/jsxKey') + +export function lookupPath (initialNode: Node, path: Path): Node | null { + return path.reduce((node, key) => { + if (!node) { + return null + } + else { + for (let i = 0; i < node.childNodes.length; i++) { + const childNode = node.childNodes[i] + if (childNode[$$key] === key) + return childNode + } + return null + } + }, initialNode) +} diff --git a/packages/vulture-dom/src/renderer/reducer.ts b/packages/vulture-dom/src/renderer/reducer.ts new file mode 100644 index 0000000..b39334c --- /dev/null +++ b/packages/vulture-dom/src/renderer/reducer.ts @@ -0,0 +1,104 @@ +import { + Patch, + PathKey, + NODE_REPLACE, + ATTRIBUTE_SET, + ATTRIBUTE_REMOVE, + CHILDREN_ADD, + CHILDREN_REMOVE, + CHILDREN_ORDER, +} from 'vulture' + +import { isElement } from '../utils' +import { renderNode } from './renderer' +import { setAttribute, removeAttribute } from './attributes' +import { $$key, lookupPath } from './path' + +// TODO: All of the errors we throw are for assumptions the patch makes about +// the previous state of the render tree. Make sure all of these errors +// (assumptions) are documented in `vulture/src/diff/patch.ts`. +// TODO: Performance testing. +export function reduceNode (parentNode: Node, patch: Patch): Node { + const pathString = patch.path.join('/') || '/' + const node = lookupPath(parentNode, patch.path) + + if (!node) + throw new Error(`Cannot patch non-existant node at path ${pathString}.`) + + switch (patch.type) { + case NODE_REPLACE: { + if (patch.path.length === 0) { + const newParentNode = renderNode(patch.node) + parentNode.parentNode.replaceChild(newParentNode, parentNode) + return newParentNode + } + else { + const newNode = renderNode(patch.node) + newNode[$$key] = patch.path[patch.path.length - 1] + node.parentNode.replaceChild(newNode, node) + return parentNode + } + } + case ATTRIBUTE_SET: { + if (isElement(node)) + setAttribute(node, patch.name, patch.value) + else + throw new Error(`Cannot set attribute for non-element at path '${pathString}'.`) + + return parentNode + } + case ATTRIBUTE_REMOVE: { + if (isElement(node)) + removeAttribute(node, patch.name) + else + throw new Error(`Cannot remove attribute for non-element at path '${pathString}'.`) + + return parentNode + } + case CHILDREN_ADD: { + const childNode = renderNode(patch.node) + childNode[$$key] = patch.key + node.appendChild(childNode) + return parentNode + } + case CHILDREN_REMOVE: { + const childNode = lookupPath(node, [patch.key]) + if (!childNode) + throw new Error(`Cannot remove child '${patch.key}' at path '${pathString}'.`) + node.removeChild(childNode) + return parentNode + } + case CHILDREN_ORDER: { + if (patch.keys.length === 0) + return node + + const childNodesIndex = new Map() + + for (const child of node.childNodes) + if (child[$$key]) + childNodesIndex.set(child[$$key], child) + + let currentNode: Node | null = node.firstChild + + for (const key of patch.keys) { + const childNode = childNodesIndex.get(key) + childNodesIndex.delete(key) + + // Child node for `key` does not exist so we couldn’t reorder child + // elements. + if (!childNode) + throw new Error(`Failed to reorder children at '${pathString}'`) + + if (currentNode !== childNode) { + node.removeChild(childNode) + node.insertBefore(childNode, currentNode) + } + + currentNode = currentNode ? currentNode.nextSibling : null + } + return node + } + default: + throw new Error(`Patch of type '${(patch as Patch).type}' is not allowed.`) + } +} diff --git a/packages/vulture-dom/src/renderer/renderer.ts b/packages/vulture-dom/src/renderer/renderer.ts new file mode 100644 index 0000000..114ac7c --- /dev/null +++ b/packages/vulture-dom/src/renderer/renderer.ts @@ -0,0 +1,37 @@ +import { + JSXNode, + JSXPrimitive, + JSXElement, + isJSXElement, + primitiveToString, + Path, + getPathKey, +} from 'vulture' + +import { $$key } from './path' +import { setAttribute } from './attributes' + +export function renderNode (jsxNode: JSXNode): Node { + if (isJSXElement(jsxNode)) return renderElement(jsxNode) + else return renderPrimitive(jsxNode) +} + +function renderPrimitive (jsxPrimitive: JSXPrimitive): Text { + return document.createTextNode(primitiveToString(jsxPrimitive)) +} + +function renderElement ({ elementName, attributes = {}, children = [] }: JSXElement): Element { + const element = document.createElement(elementName) + + for (const name in attributes) + if (attributes.hasOwnProperty(name)) + setAttribute(element, name, attributes[name]) + + children.forEach((jsxChild, i) => { + const child = renderNode(jsxChild) + child[$$key] = getPathKey(jsxChild, i) + element.appendChild(child) + }) + + return element +} diff --git a/packages/vulture-dom/src/utils/README.md b/packages/vulture-dom/src/utils/README.md new file mode 100644 index 0000000..21348dc --- /dev/null +++ b/packages/vulture-dom/src/utils/README.md @@ -0,0 +1,2 @@ +# Vulture DOM Utils +TODO diff --git a/packages/vulture-dom/src/utils/__tests__/node.js b/packages/vulture-dom/src/utils/__tests__/node.js new file mode 100644 index 0000000..363a18b --- /dev/null +++ b/packages/vulture-dom/src/utils/__tests__/node.js @@ -0,0 +1,9 @@ +import 'test-dom' + +import test from 'ava' +import { isElement } from '../node' + +test('isElement will tell an element from other node types', t => { + t.true(isElement(document.createElement('div'))) + t.false(isElement(document.createTextNode('hello'))) +}) diff --git a/packages/vulture-dom/src/utils/index.ts b/packages/vulture-dom/src/utils/index.ts new file mode 100644 index 0000000..814ffd9 --- /dev/null +++ b/packages/vulture-dom/src/utils/index.ts @@ -0,0 +1 @@ +export { isElement } from './node' diff --git a/packages/vulture-dom/src/utils/node.ts b/packages/vulture-dom/src/utils/node.ts new file mode 100644 index 0000000..bf4f67e --- /dev/null +++ b/packages/vulture-dom/src/utils/node.ts @@ -0,0 +1,5 @@ +const ELEMENT_NODE = 1 + +export function isElement (node: Node): node is Element { + return node.nodeType === ELEMENT_NODE +} diff --git a/packages/vulture-string/node_modules/vulture/index.d.ts b/packages/vulture-string/node_modules/vulture/index.d.ts new file mode 100644 index 0000000..70aa944 --- /dev/null +++ b/packages/vulture-string/node_modules/vulture/index.d.ts @@ -0,0 +1 @@ +export * from '../../../vulture/src' diff --git a/packages/vulture-string/node_modules/vulture/index.js b/packages/vulture-string/node_modules/vulture/index.js new file mode 100644 index 0000000..1e337a6 --- /dev/null +++ b/packages/vulture-string/node_modules/vulture/index.js @@ -0,0 +1 @@ +module.exports = require('../../../vulture/src') diff --git a/packages/vulture-string/package.json b/packages/vulture-string/package.json new file mode 100644 index 0000000..9e0a360 --- /dev/null +++ b/packages/vulture-string/package.json @@ -0,0 +1,7 @@ +{ + "name": "vulture-string", + "version": "4.0.0-alpha.0", + "dependencies": { + "vulture": "4.0.0-alpha.0" + } +} diff --git a/packages/vulture-string/src/__tests__/renderer.js b/packages/vulture-string/src/__tests__/renderer.js new file mode 100644 index 0000000..db6629d --- /dev/null +++ b/packages/vulture-string/src/__tests__/renderer.js @@ -0,0 +1,50 @@ +import test from 'ava' +import { renderNode, renderOpeningTag, renderClosingTag } from '../renderer' + +test('renderNode will render primitives correctly', t => { + t.is(renderNode(undefined), '') + t.is(renderNode(null), '') + t.is(renderNode(true), '') + t.is(renderNode(false), '') + t.is(renderNode(42), '42') + t.is(renderNode('hello'), 'hello') +}) + +test('renderNode will render elements', t => { + t.is(renderNode({ elementName: 'a', attributes: {}, children: [] }), '') +}) + +test('renderNode will render elements with children', t => { + t.is( + renderNode({ + elementName: 'a', + attributes: {}, + children: [ + 42, + ' hello ', + { elementName: 'b', attributes: {}, children: [] } + ], + }), + '42 hello ' + ) +}) + +test('renderNode will render elements without attributes', t => { + t.is(renderNode({ elementName: 'a', children: [] }), '') +}) + +test('renderNode will render elements without children', t => { + t.is(renderNode({ elementName: 'a', attributes: { hello: 'world' } }), '') +}) + +test('renderOpeningTag will render the opening tag', t => { + t.is(renderOpeningTag('a', {}), '') +}) + +test('renderOpeningTag will render the opening tag with attributes', t => { + t.is(renderOpeningTag('a', { hello: 'world', answer: 42 }), '') +}) + +test('renderOpeningTag will render the closing tag', t => { + t.is(renderClosingTag('a'), '') +}) diff --git a/packages/vulture-string/src/index.ts b/packages/vulture-string/src/index.ts new file mode 100644 index 0000000..1b3ac5f --- /dev/null +++ b/packages/vulture-string/src/index.ts @@ -0,0 +1,7 @@ +import { createRender, createRenderStream, JSXElementAsync } from 'vulture' +import * as renderer from './renderer' + +export { renderer } + +export const render = createRender(renderer) +export const renderStream = createRenderStream(renderer) diff --git a/packages/vulture-string/src/renderer.ts b/packages/vulture-string/src/renderer.ts new file mode 100644 index 0000000..e2f0877 --- /dev/null +++ b/packages/vulture-string/src/renderer.ts @@ -0,0 +1,45 @@ +import { + JSXNode, + JSXElement, + ElementName, + Attributes, + isJSXElement, + primitiveToString, + Renderer, + PartialRenderer, +} from 'vulture' + +export function renderNode (jsxNode: JSXNode): string { + if (isJSXElement(jsxNode)) return renderElement(jsxNode) + else return primitiveToString(jsxNode) +} + +function renderElement ({ elementName, attributes, children }: JSXElement): string { + if (!children) { + return `<${elementName}${renderAttributes(attributes || {})}/>` + } + else { + return ( + renderOpeningTag(elementName, attributes || {}) + + children.map(child => renderNode(child)).join('') + + renderClosingTag(elementName) + ) + } +} + +export function renderOpeningTag (elementName: ElementName, attributes: Attributes): string { + return `<${elementName}${renderAttributes(attributes)}>` +} + +export function renderClosingTag (elementName: ElementName): string { + return `` +} + +function renderAttributes (attributes: Attributes): string { + let attributesString = '' + + for (let key in attributes) + attributesString += ` ${key}="${attributes[key]}"` + + return attributesString +} diff --git a/packages/vulture/package.json b/packages/vulture/package.json new file mode 100644 index 0000000..f7865df --- /dev/null +++ b/packages/vulture/package.json @@ -0,0 +1,4 @@ +{ + "name": "vulture", + "version": "4.0.0-alpha.0" +} diff --git a/packages/vulture/src/async/README.md b/packages/vulture/src/async/README.md new file mode 100644 index 0000000..35ae06e --- /dev/null +++ b/packages/vulture/src/async/README.md @@ -0,0 +1,15 @@ +# Vulture Async +A big selling point of Vulture is that it allows for asynchronous JSX at a primitive level. This allows for some pretty awesome things. + +Basically this module takes all of the synchronous interfaces from the other modules and composes them into powerful asynchronous interfaces. + +Most renderer libraries will also want to use some exported utilities like the `createRender` function to create a general purpose render functions. + +## Exports +- `JSXAsync` +- `JSXNodeAsync` +- `JSXElementAsync` +- `ChildrenAsync` +- `diffAsync` +- `createRender` +- `createRenderStream` diff --git a/packages/vulture/src/async/__tests__/diff.js b/packages/vulture/src/async/__tests__/diff.js new file mode 100644 index 0000000..27d9461 --- /dev/null +++ b/packages/vulture/src/async/__tests__/diff.js @@ -0,0 +1,55 @@ +import util from 'util' +import test from 'ava' +import { diffAsync } from '../diff' + +test('diffAsync will diff an async JSX node', t => { + t.plan(1) + return ( + diffAsync({ + elementName: 'a', + children: [ + Observable.of( + { elementName: 'b' }, + { elementName: 'b', attributes: { hello: 'world' } }, + { elementName: 'b', attributes: { hello: 'moon' }, children: [1] }, + { elementName: 'b', children: [1, 2] }, + { elementName: 'b', children: [1, 3] }, + { elementName: 'b', children: [1] }, + 42, + ).delay(0), + ], + }) + .toArray() + .do(history => t.deepEqual(history, [ + [ + { type: 'NODE_REPLACE', path: [], node: { elementName: 'a', attributes: {}, children: [null] } }, + ], + [ + { type: 'CHILDREN_ADD', path: [], key: '0', node: { elementName: 'b' } }, + ], + [ + { type: 'ATTRIBUTE_SET', path: ['0'], name: 'hello', value: 'world' }, + ], + [ + { type: 'ATTRIBUTE_SET', path: ['0'], name: 'hello', value: 'moon' }, + { type: 'CHILDREN_ADD', path: ['0'], key: '0', node: 1 }, + { type: 'CHILDREN_ORDER', path: ['0'], keys: ['0'] }, + ], + [ + { type: 'ATTRIBUTE_REMOVE', path: ['0'], name: 'hello' }, + { type: 'CHILDREN_ADD', path: ['0'], key: '1', node: 2 }, + { type: 'CHILDREN_ORDER', path: ['0'], keys: ['0', '1'] }, + ], + [ + { type: 'NODE_REPLACE', path: ['0', '1'], node: 3 }, + ], + [ + { type: 'CHILDREN_REMOVE', path: ['0'], key: '1' }, + { type: 'CHILDREN_ORDER', path: ['0'], keys: ['0'] }, + ], + [ + { type: 'NODE_REPLACE', path: ['0'], node: 42 }, + ], + ])) + ) +}) diff --git a/packages/vulture/src/async/__tests__/observable.js b/packages/vulture/src/async/__tests__/observable.js new file mode 100644 index 0000000..9f71cf5 --- /dev/null +++ b/packages/vulture/src/async/__tests__/observable.js @@ -0,0 +1,29 @@ +import test from 'ava' +import { intoObservable } from '../observable' + +test('intoObservable does nothing to observables', t => { + t.plan(1) + return ( + intoObservable(Observable.of(1, 2, 3)) + .toArray() + .do(history => t.deepEqual(history, [1, 2, 3])) + ) +}) + +test('intoObservable will turn a promise into an observable', t => { + t.plan(1) + return ( + intoObservable(Promise.resolve(42)) + .toArray() + .do(history => t.deepEqual(history, [42])) + ) +}) + +test('intoObservable will turn a plain value into an observable', t => { + t.plan(1) + return ( + intoObservable(42) + .toArray() + .do(history => t.deepEqual(history, [42])) + ) +}) diff --git a/packages/vulture/src/async/__tests__/stream.js b/packages/vulture/src/async/__tests__/stream.js new file mode 100644 index 0000000..a0eb323 --- /dev/null +++ b/packages/vulture/src/async/__tests__/stream.js @@ -0,0 +1,106 @@ +import test from 'ava' +import { createRenderStream } from '../stream' + +const renderStream = createRenderStream({ + renderNode: node => node, + renderOpeningTag: (elementName, attributes) => ({ openingTag: true, elementName, attributes }), + renderClosingTag: elementName => ({ closingTag: true, elementName }), +}) + +test('renderStream only outputs the last node', t => { + t.plan(1) + return ( + renderStream(Observable.of(1, 2, 3)) + .toArray() + .do(stream => t.deepEqual(stream, [3])) + ) +}) + +test('renderStream simply renders elements with no children', t => { + t.plan(2) + return Observable.merge( + renderStream({ elementName: 'a' }) + .toArray() + .do(stream => t.deepEqual(stream, [{ elementName: 'a' }])), + + renderStream({ elementName: 'a', children: [] }) + .toArray() + .do(stream => t.deepEqual(stream, [{ elementName: 'a', children: [] }])) + ) +}) + +test('renderStream will render elements in parts', t => { + t.plan(1) + return ( + renderStream({ elementName: 'a', children: [1, 2, 3] }) + .toArray() + .do(stream => t.deepEqual(stream, [ + { openingTag: true, elementName: 'a', attributes: {} }, + 1, + 2, + 3, + { closingTag: true, elementName: 'a' }, + ])) + ) +}) + +test('renderStream will render children asap', t => { + t.plan(2) + let timedOut = false + // We want a timeout that’s not too short that we fail quickly, but also a + // timeout that’s not too long that we aren’t testing what we want to test. + // + // We want to test that the third child does not emit itself 10ms after the + // second child emits. The third child should have resolved before the second + // child resolved, therefore we shouldn’t wait longer for it. + // + // Testing time is hard in tests… + setTimeout(() => (timedOut = true), 45) + return ( + renderStream({ + elementName: 'a', + children: [ + Observable.of(1).delay(10), + Observable.of(2).delay(30), + Observable.of(3).delay(10) + ], + }) + .toArray() + .do(() => t.false(timedOut)) + .do(stream => t.deepEqual(stream, [ + { openingTag: true, elementName: 'a', attributes: {} }, + 1, + 2, + 3, + { closingTag: true, elementName: 'a' }, + ])) + ) +}) + +test('renderStream will render nested elements in parts', t => { + t.plan(1) + return ( + renderStream({ + elementName: 'a', + children: [{ + elementName: 'b', + children: [{ + elementName: 'c', + children: [{ + elementName: 'd', + }] + }], + }] + }) + .toArray() + .do(stream => t.deepEqual(stream, [ + { openingTag: true, elementName: 'a', attributes: {} }, + { openingTag: true, elementName: 'b', attributes: {} }, + { openingTag: true, elementName: 'c', attributes: {} }, + { elementName: 'd' }, + { closingTag: true, elementName: 'c' }, + { closingTag: true, elementName: 'b' }, + { closingTag: true, elementName: 'a' }, + ])) + ) +}) diff --git a/packages/vulture/src/async/__tests__/sync.js b/packages/vulture/src/async/__tests__/sync.js new file mode 100644 index 0000000..28696a5 --- /dev/null +++ b/packages/vulture/src/async/__tests__/sync.js @@ -0,0 +1,208 @@ +import test from 'ava' +import { flattenAsyncJSX } from '../sync' + +test('flattenAsyncJSX will correctly turn an async structure into a sync one', t => { + t.plan(1) + return ( + flattenAsyncJSX(Promise.resolve({ + elementName: 'a', + children: [ + Promise.resolve({ elementName: 'b' }), + Observable.of({ elementName: 'c1' }, { elementName: 'c2' }).delay(0), + { + elementName: 'd', + children: [ + Observable.of(1, 2, { + elementName: 'e', + children: [ + Promise.resolve(42), + Observable.of('hello', 'world').delay(0), + ], + }).delay(0) + ], + }, + ], + })) + .toArray() + .do(history => t.deepEqual(history, [ + { + elementName: 'a', + attributes: {}, + children: [ + null, + null, + { + elementName: 'd', + attributes: {}, + children: [ + null, + ], + }, + ] + }, + { + elementName: 'a', + attributes: {}, + children: [ + { elementName: 'b' }, + null, + { + elementName: 'd', + attributes: {}, + children: [ + null, + ], + }, + ] + }, + { + elementName: 'a', + attributes: {}, + children: [ + { elementName: 'b' }, + { elementName: 'c1' }, + { + elementName: 'd', + attributes: {}, + children: [ + null, + ], + }, + ] + }, + { + elementName: 'a', + attributes: {}, + children: [ + { elementName: 'b' }, + { elementName: 'c2' }, + { + elementName: 'd', + attributes: {}, + children: [ + null, + ], + }, + ] + }, + { + elementName: 'a', + attributes: {}, + children: [ + { elementName: 'b' }, + { elementName: 'c2' }, + { + elementName: 'd', + attributes: {}, + children: [ + 1, + ], + }, + ] + }, + { + elementName: 'a', + attributes: {}, + children: [ + { elementName: 'b' }, + { elementName: 'c2' }, + { + elementName: 'd', + attributes: {}, + children: [ + 2, + ], + }, + ] + }, + { + elementName: 'a', + attributes: {}, + children: [ + { elementName: 'b' }, + { elementName: 'c2' }, + { + elementName: 'd', + attributes: {}, + children: [ + { + elementName: 'e', + attributes: {}, + children: [ + null, + null, + ] + }, + ], + }, + ] + }, + { + elementName: 'a', + attributes: {}, + children: [ + { elementName: 'b' }, + { elementName: 'c2' }, + { + elementName: 'd', + attributes: {}, + children: [ + { + elementName: 'e', + attributes: {}, + children: [ + 42, + null, + ] + }, + ], + }, + ] + }, + { + elementName: 'a', + attributes: {}, + children: [ + { elementName: 'b' }, + { elementName: 'c2' }, + { + elementName: 'd', + attributes: {}, + children: [ + { + elementName: 'e', + attributes: {}, + children: [ + 42, + 'hello', + ] + }, + ], + }, + ] + }, + { + elementName: 'a', + attributes: {}, + children: [ + { elementName: 'b' }, + { elementName: 'c2' }, + { + elementName: 'd', + attributes: {}, + children: [ + { + elementName: 'e', + attributes: {}, + children: [ + 42, + 'world', + ] + }, + ], + }, + ] + }, + ])) + ) +}) diff --git a/packages/vulture/src/async/diff.ts b/packages/vulture/src/async/diff.ts new file mode 100644 index 0000000..4784bbf --- /dev/null +++ b/packages/vulture/src/async/diff.ts @@ -0,0 +1,27 @@ +import { JSXNode, JSXElement } from '../jsx' +import { Path, Patch, diff } from '../diff' +import { JSXAsync, JSXElementAsync } from './jsx' +import { flattenAsyncJSX } from './sync' + +/** + * Diffs an asynchronous JSX node with itself overtime. Because asynchronous + * JSX nodes are internally converted into `Observable`s it is possible that + * the node will update itself over time. Therefore we need to diff these + * versions to incrementally update our render tree. + */ +export function diffAsync (jsx: JSXAsync, initialJSX: JSXNode = null, path: Path = []) { + const jsxs = flattenAsyncJSX(jsx) + + return new Observable(observer => { + let lastJSX: JSXNode = initialJSX + + return jsxs.subscribe( + nextJSX => { + observer.next(diff(lastJSX, nextJSX, path)) + lastJSX = nextJSX + }, + error => observer.error(error), + () => observer.complete() + ) + }) +} diff --git a/packages/vulture/src/async/index.ts b/packages/vulture/src/async/index.ts new file mode 100644 index 0000000..3222782 --- /dev/null +++ b/packages/vulture/src/async/index.ts @@ -0,0 +1,4 @@ +export { JSXAsync, JSXNodeAsync, JSXElementAsync, ChildrenAsync } from './jsx' +export { diffAsync } from './diff' +export { createRender } from './render' +export { createRenderStream } from './stream' diff --git a/packages/vulture/src/async/jsx.ts b/packages/vulture/src/async/jsx.ts new file mode 100644 index 0000000..c57b4a5 --- /dev/null +++ b/packages/vulture/src/async/jsx.ts @@ -0,0 +1,40 @@ +import { JSXPrimitive, ElementName, Attributes } from '../jsx' + +/** + * The exciting part about this asynchronous variant of Vulture’s JSX is it + * allows us to embed `Promise`s and `Observable`s *directly* into our JSX. + * Behind the scenes, Vulture converts everything into an `Observable` which + * lets Vulture support any spec-defined format that can be simply enoughed + * turned into an `Observable` (the Vulture transformation process is a little + * more complex than `Observable.from`). + * + * Although in the type system we only list `Promise` and `Observable`, we + * actually support any thenable or object with a `Symbol.observable`. + */ +export type JSXAsync = + JSXNodeAsync + | Promise + | Observable + +/** + * The only reason we have `JSXNodeAsync` is so that we have a type for + * `JSXElementAsync`. + */ +export type JSXNodeAsync = JSXPrimitive | JSXElementAsync + +/** + * The only difference between `JSXElementAsync` and `JSXElement` is the + * children. Asynchronous JSX elements need to allow asynchronous children, so + * `Promise` or `Observable` `JSXNode`s are allowed. + */ +export interface JSXElementAsync { + elementName: ElementName + attributes?: Attributes + children?: ChildrenAsync +} + +/** + * Exact same as the synchronous version, except for it’s an array of + * `JSXAsync` which allows `Promise`s and `Observable`s. + */ +export type ChildrenAsync = JSXAsync[] diff --git a/packages/vulture/src/async/observable.ts b/packages/vulture/src/async/observable.ts new file mode 100644 index 0000000..25be78c --- /dev/null +++ b/packages/vulture/src/async/observable.ts @@ -0,0 +1,46 @@ +/** + * Converts a plain value, promise, or observable into an observable. + */ +// TODO: When Typescript 2 goes out of beta, see why these typeguards aren’t +// working. +export function intoObservable (box: T | Promise | Observable): Observable { + if (isObservable(box)) { + return Observable.from(box) + } + else if (isPromise(box)) { + return new Observable(observer => { + // TODO: here, and… + (box as Promise).then( + value => { + if (observer.closed) return + observer.next(value) + observer.complete() + }, + (error: Error) => { + if (observer.closed) return + observer.error(error) + observer.complete() + } + ) + return () => {} + }) + } + else { + return new Observable(observer => { + // TODO: here + observer.next(box as T) + observer.complete() + return () => {} + }) + } +} + +const $$observable = Symbol['observable'] ? Symbol['observable'] : '@@observable' + +function isObservable (value: any): value is Observable { + return value != null && typeof value === 'object' && value[$$observable] +} + +function isPromise (value: any): value is Promise { + return value != null && typeof value === 'object' && typeof value.then === 'function' +} diff --git a/packages/vulture/src/async/render.ts b/packages/vulture/src/async/render.ts new file mode 100644 index 0000000..510d6ba --- /dev/null +++ b/packages/vulture/src/async/render.ts @@ -0,0 +1,18 @@ +import { JSXNode } from '../jsx' +import { Renderer, ReducerRenderer } from '../renderer' +import { JSXAsync, JSXElementAsync } from './jsx' +import { flattenAsyncJSX } from './sync' +import { diffAsync } from './diff' + +export function createRender (renderer: Renderer) { + return function render (jsx: JSXAsync) { + const jsxs = flattenAsyncJSX(jsx) + return new Observable(observer => ( + jsxs.subscribe( + node => observer.next(renderer.renderNode(node)), + error => observer.error(error), + () => observer.complete() + ) + )) + } +} diff --git a/packages/vulture/src/async/stream.ts b/packages/vulture/src/async/stream.ts new file mode 100644 index 0000000..e253d7f --- /dev/null +++ b/packages/vulture/src/async/stream.ts @@ -0,0 +1,126 @@ +import { JSXElement, isJSXPrimitive } from '../jsx' +import { PartialRenderer } from '../renderer' +import { JSXAsync, JSXNodeAsync, JSXElementAsync } from './jsx' +import { intoObservable } from './observable' + +/** + * The `PartialRenderer` interface gives us the power to *stream* our + * asynchronous JSX. Some of our asynchronous JSX nodes will complete earlier + * than others allowing us to immeadiately send them down while still waiting + * for the rest. We can do this because `PartialRenderer` allows us to render + * an element’s opening and closing tags *seperate* from the element’s + * children. + * + * So for example, say we have three promise child nodes that resolve after one + * second, two seconds, and three seconds respectively. This function creates a + * function that creates an observable that will emit the opening tag for the + * root element, then render each child in one second increments, then finally + * emits the parent element’s closing tag. + * + * If the renderer can emit partial values, and a consumer can take and + * construct them—this function provides exciting performance possibilities. + * This is one of Vulture’s unique advantages. + */ +export function createRenderStream (renderer: PartialRenderer) { + // We call this function recursively, so we need to give it a name before it + // gets returned. + return function renderStream (jsx: JSXAsync): Observable { + const jsxs = intoObservable(jsx) + + return new Observable(observer => { + const subscriptions: Subscription[] = [] + let node: JSXNodeAsync = null + + subscriptions.push(jsxs.subscribe( + // Keep track of the last node “next”ed. + nextNode => (node = nextNode), + error => observer.error(error), + // Wait for our JSX node to complete before streaming anything. + () => { + if (isJSXPrimitive(node)) { + observer.next(renderer.renderNode(node)) + observer.complete() + } + else if (!node.children || node.children.length === 0) { + observer.next(renderer.renderNode(node as JSXElement)) + observer.complete() + } + else { + const parentElement: JSXElementAsync = node + const children: JSXAsync[] = node.children + let streamingChild = 0 + + // Open the parent element tag and start streaming its contents! + observer.next(renderer.renderOpeningTag( + parentElement.elementName, + parentElement.attributes || {} + )) + + type ChildState = { + node: JSXAsync, + queuedValues: (A | B | C)[], + complete: boolean, + } + + const childrenState: ChildState[] = children.map(child => ({ + node: child, + queuedValues: [], + complete: false, + })) + + childrenState.forEach((childState, thisChild) => { + // Side effects! We watch the child’s stream and update its state + // accordingly. + subscriptions.push(renderStream(childState.node).subscribe( + value => { + // Send the data to the observer if this child is live, + // otherwise add it to the child’s queue. + if (streamingChild === thisChild) observer.next(value) + else childState.queuedValues.push(value) + }, + error => observer.error(error), + () => { + // This child has completed, so set its completed value to + // `true`. + childState.complete = true + + // If this was the currently streaming child it has a + // responsibility to set the next streaming child and stream + // everything that’s waiting in the queue. + if (streamingChild === thisChild) { + while (true) { + // Increment the streaming child. + streamingChild++ + + // If streaming child is greater than the last child + // index, we’ve reached the end. Complete the observable + // and break out of the loop. + if (streamingChild > children.length - 1) { + observer.next(renderer.renderClosingTag(parentElement.elementName)) + observer.complete() + break + } + + // Stream the queued values from the streaming child. + const childState = childrenState[streamingChild] + childState.queuedValues.forEach(value => observer.next(value)) + + // If the child has not yet completed, it is truly the + // new streaming child and we should break out of this + // while loop. Otherwise, the next child might be the + // next streaming child. + if (!childState.complete) break + } + } + } + )) + }) + } + } + )) + + // Unsubscribe from all the things! + return () => subscriptions.forEach(subscription => subscription.unsubscribe()) + }) + } +} diff --git a/packages/vulture/src/async/sync.ts b/packages/vulture/src/async/sync.ts new file mode 100644 index 0000000..abe6ae8 --- /dev/null +++ b/packages/vulture/src/async/sync.ts @@ -0,0 +1,104 @@ +import { JSXNode, JSXElement, isJSXPrimitive } from '../jsx' +import { intoObservable } from './observable' +import { JSXAsync } from './jsx' + +/** + * Takes an asynchronous JSX element and flattens it into an observable of + * synchronous JSX nodes. Great and easy function for making most synchronous + * interfaces asynchronous. + * + * Also note that many elements will be strictly equal across many observable + * emissions, so it’s fairly easy to reduce computations using the synchronous + * observable using strict equality checks (`===`). + */ +export function flattenAsyncJSX (jsx: JSXAsync): Observable { + const jsxs = intoObservable(jsx) + + return new Observable(observer => { + let childSubscriptions: Subscription[] = [] + + const unsubscribeChildren = () => { + childSubscriptions.forEach(subscription => subscription.unsubscribe()) + childSubscriptions = [] + } + + let completeCount = 1 + + const complete = () => { + completeCount -= 1 + if (completeCount === 0) { + unsubscribeChildren() + observer.complete() + } + } + + const subscription = jsxs.subscribe( + node => { + // Reset complete count to one and unsubscribe all of our children. + unsubscribeChildren() + completeCount = 1 + + if (isJSXPrimitive(node)) { + observer.next(node) + } + else if (!node.children || node.children.length === 0) { + observer.next(node as JSXElement) + } + else { + const { elementName, attributes = {} } = node + + // We don’t want to emit any values while we are still `map`ing over + // the array. + let ready = false + + // Map all of the children to null. `childrenSync` is not + // immutable. Therefore whenever we call `observer.next` we need to + // make a shallow copy. + const childrenSync: JSXNode[] = node.children.map(() => null) + + node.children.forEach((childAsync, i) => { + completeCount += 1 + childSubscriptions.push(flattenAsyncJSX(childAsync).subscribe( + childSync => { + // Update our child for the `i` position. + childrenSync[i] = childSync + // If we are not yet ready, return so we don’t send an + // intermediate value to our observer. + if (!ready) return + observer.next({ + elementName, + attributes, + children: arrayShallowCopy(childrenSync), + }) + }, + // Propagate the error. + error => observer.error(error), + complete + )) + }) + + // Send an initial version. + observer.next({ + elementName, + attributes, + children: arrayShallowCopy(childrenSync), + }) + + // Ok, we can send values now. + ready = true + } + }, + error => observer.error(error), + complete + ) + + return () => { + subscription.unsubscribe() + unsubscribeChildren() + } + }) +} + +function arrayShallowCopy (array: T[]): T[] { + return array.map(item => item) +} diff --git a/packages/vulture/src/diff/README.md b/packages/vulture/src/diff/README.md new file mode 100644 index 0000000..915bd54 --- /dev/null +++ b/packages/vulture/src/diff/README.md @@ -0,0 +1,13 @@ +# Vulture Diff Algorithm +This module contains the code for vulture’s JSX diffing. + +Instead of rerendering the app for every new JSX object, vulture rerenders the minimal amount of changes. Therefore vulture needs a diffing algorithm to determine what in the JSX objects has changed. + +The diffing model used by vulture is inspired by Redux. Vulture uses tagged action objects to define patches to the rendered tree and a renderer reduces its tree with the actions (although not always in an immutable way 😉). + +## Exports +- `diff` +- `Patch` +- `Path` +- `PathKey` +- `getPathKey` diff --git a/packages/vulture/src/diff/__tests__/diff.js b/packages/vulture/src/diff/__tests__/diff.js new file mode 100644 index 0000000..26a90c4 --- /dev/null +++ b/packages/vulture/src/diff/__tests__/diff.js @@ -0,0 +1,164 @@ +import test from 'ava' + +import { + Patch, + NODE_REPLACE, + ATTRIBUTE_SET, + ATTRIBUTE_REMOVE, + CHILDREN_ADD, + CHILDREN_ORDER, + CHILDREN_REMOVE, +} from '../patch' + +import { diffNode, diffElement, diffAttributes, diffChildren } from '../diff' + +test('diffNode will have no patches for the same node', t => { + const jsx = { elementName: 'a' } + t.deepEqual(diffNode(42, 42, []), []) + t.deepEqual(diffNode(jsx, jsx, []), []) +}) + +test('diffNode will replace different nodes', t => { + t.deepEqual(diffNode(1, 2, []), [{ type: NODE_REPLACE, path: [], node: 2 }]) + t.deepEqual(diffNode(true, 'hello', []), [{ type: NODE_REPLACE, path: [], node: 'hello' }]) + t.deepEqual(diffNode('hello', { elementName: 'a' }, []), [{ type: NODE_REPLACE, path: [], node: { elementName: 'a' } }]) + t.deepEqual(diffNode({ elementName: 'a' }, 'world', []), [{ type: NODE_REPLACE, path: [], node: 'world' }]) +}) + +test('diffNode will pass through the path', t => { + t.deepEqual(diffNode(1, 2, ['a', 'b', 'c']), [{ type: NODE_REPLACE, path: ['a', 'b', 'c'], node: 2 }]) + t.deepEqual(diffNode(1, 2, ['d', 'e']), [{ type: NODE_REPLACE, path: ['d', 'e'], node: 2 }]) +}) + +test('diffElement will return nothing for identical elements', t => { + const jsx = { elementName: 'a' } + t.deepEqual(diffElement(jsx, jsx, []), []) + t.deepEqual(diffElement({ elementName: 'b' }, { elementName: 'b' }, []), []) +}) + +test('diffElement will return node replace when names are different', t => { + t.deepEqual(diffElement({ elementName: 'a' }, { elementName: 'b' }, []), [{ type: NODE_REPLACE, path: [], node: { elementName: 'b' } }]) +}) + +test('diffAttributes will set new attributes', t => { + t.deepEqual(diffAttributes({}, { a: 1 }, []), [{ type: ATTRIBUTE_SET, path: [], name: 'a', value: 1 }]) +}) + +test('diffAttributes will replace old attributes', t => { + t.deepEqual(diffAttributes({ a: 1 }, { a: 2 }, []), [{ type: ATTRIBUTE_SET, path: [], name: 'a', value: 2 }]) +}) + +test('diffAttributes will remove old attributes', t => { + t.deepEqual(diffAttributes({ a: 1 }, {}, []), [{ type: ATTRIBUTE_REMOVE, path: [], name: 'a' }]) +}) + +test('diffAttributes will diff attributes', t => { + t.deepEqual(diffAttributes({ a: 1, b: 2, c: 3 }, { a: 1, b: 3, d: 4 }, []), [ + { type: ATTRIBUTE_SET, path: [], name: 'b', value: 3 }, + { type: ATTRIBUTE_SET, path: [], name: 'd', value: 4 }, + { type: ATTRIBUTE_REMOVE, path: [], name: 'c' }, + ]) +}) + +test('diffChildren will add a child', t => { + t.deepEqual(diffChildren([], [1], []), [ + { type: CHILDREN_ADD, path: [], key: '0', node: 1 }, + { type: CHILDREN_ORDER, path: [], keys: ['0'] }, + ]) + t.deepEqual(diffChildren([1], [1, 2], []), [ + { type: CHILDREN_ADD, path: [], key: '1', node: 2 }, + { type: CHILDREN_ORDER, path: [], keys: ['0', '1'] }, + ]) + t.deepEqual(diffChildren([1], [1, 2, 3], []), [ + { type: CHILDREN_ADD, path: [], key: '1', node: 2 }, + { type: CHILDREN_ADD, path: [], key: '2', node: 3 }, + { type: CHILDREN_ORDER, path: [], keys: ['0', '1', '2'] }, + ]) +}) + +test('diffChildren will replace poorly keyed nodes instead of adding', t => { + t.deepEqual(diffChildren([2], [1, 2], []), [ + { type: NODE_REPLACE, path: ['0'], node: 1 }, + { type: CHILDREN_ADD, path: [], key: '1', node: 2 }, + { type: CHILDREN_ORDER, path: [], keys: ['0', '1'] }, + ]) + t.deepEqual(diffChildren([2, 3], [1, 2, 3], []), [ + { type: NODE_REPLACE, path: ['0'], node: 1 }, + { type: NODE_REPLACE, path: ['1'], node: 2 }, + { type: CHILDREN_ADD, path: [], key: '2', node: 3 }, + { type: CHILDREN_ORDER, path: [], keys: ['0', '1', '2'] }, + ]) +}) + +test('diffChildren will add a well keyed node', t => { + t.deepEqual(diffChildren([ + { elementName: 'a', attributes: { key: 'a' } }, + ], [ + { elementName: 'b', attributes: { key: 'b' } }, + { elementName: 'a', attributes: { key: 'a' } }, + ], []), [ + { type: CHILDREN_ADD, path: [], key: 'b', node: { elementName: 'b', attributes: { key: 'b' } } }, + { type: CHILDREN_ORDER, path: [], keys: ['b', 'a'] }, + ]) +}) + +test('diffChildren will remove a child', t => { + t.deepEqual(diffChildren([1], [], []), [ + { type: CHILDREN_REMOVE, path: [], key: '0' }, + { type: CHILDREN_ORDER, path: [], keys: [] }, + ]) + t.deepEqual(diffChildren([1, 2], [1], []), [ + { type: CHILDREN_REMOVE, path: [], key: '1' }, + { type: CHILDREN_ORDER, path: [], keys: ['0'] }, + ]) + t.deepEqual(diffChildren([1, 2, 3], [1], []), [ + { type: CHILDREN_REMOVE, path: [], key: '1' }, + { type: CHILDREN_REMOVE, path: [], key: '2' }, + { type: CHILDREN_ORDER, path: [], keys: ['0'] }, + ]) +}) + +test('diffChildren will replace poorly keyed children instead of removing', t => { + t.deepEqual(diffChildren([1, 2], [2], []), [ + { type: NODE_REPLACE, path: ['0'], node: 2 }, + { type: CHILDREN_REMOVE, path: [], key: '1' }, + { type: CHILDREN_ORDER, path: [], keys: ['0'] }, + ]) +}) + +test('diffChildren will remove well keyed children', t => { + t.deepEqual(diffChildren([ + { elementName: 'a', attributes: { key: 'a' } }, + { elementName: 'b', attributes: { key: 'b' } }, + ], [ + { elementName: 'b', attributes: { key: 'b' } }, + ], []), [ + { type: CHILDREN_REMOVE, path: [], key: 'a' }, + { type: CHILDREN_ORDER, path: [], keys: ['b'] }, + ]) +}) + +test('diffChildren will replace poorly keyed children instead of ordering', t => { + t.deepEqual(diffChildren([2, 1, 3], [1, 2, 3], []), [ + { type: NODE_REPLACE, path: ['0'], node: 1 }, + { type: NODE_REPLACE, path: ['1'], node: 2 }, + ]) +}) + +test('diffChildren will order well keyed children', t => { + t.deepEqual(diffChildren([ + { elementName: 'b', attributes: { key: 'b' } }, + { elementName: 'a', attributes: { key: 'a' } }, + { elementName: 'c', attributes: { key: 'c' } }, + ], [ + { elementName: 'a', attributes: { key: 'a' } }, + { elementName: 'b', attributes: { key: 'b' } }, + { elementName: 'c', attributes: { key: 'c' } }, + ], []), [ + { type: CHILDREN_ORDER, path: [], keys: ['a', 'b', 'c'] } + ]) +}) + +test('diffChildren will correctly patch falsey values', t => { + t.deepEqual(diffChildren([null], [42], []), [{ type: NODE_REPLACE, path: ['0'], node: 42 }]) +}) diff --git a/packages/vulture/src/diff/__tests__/path.js b/packages/vulture/src/diff/__tests__/path.js new file mode 100644 index 0000000..6c9b574 --- /dev/null +++ b/packages/vulture/src/diff/__tests__/path.js @@ -0,0 +1,26 @@ +import test from 'ava' +import { getPathKey } from '../path' + +test('getPathKey will just be the number for primitives', t => { + t.is(getPathKey(undefined, 1), '1') + t.is(getPathKey(null, 2), '2') + t.is(getPathKey(true, 3), '3') + t.is(getPathKey(42, 4), '4') + t.is(getPathKey('42', 5), '5') +}) + +test('getPathKey will be the number for an element without the correct attributes', t => { + t.is(getPathKey({ elementName: 'a', attributes: {} }, 6), '6') +}) + +test('getPathKey will be the `id` attribute if it exists', t => { + t.is(getPathKey({ elementName: 'a', attributes: { id: 'hello' } }, 7), 'hello') +}) + +test('getPathKey will be the `key` attribute if it exists', t => { + t.is(getPathKey({ elementName: 'a', attributes: { key: 'world' } }, 8), 'world') +}) + +test('getPathKey will use `id` over `key` if both exist', t => { + t.is(getPathKey({ elementName: 'a', attributes: { id: 'hello', key: 'world' } }, 9), 'hello') +}) diff --git a/packages/vulture/src/diff/diff.ts b/packages/vulture/src/diff/diff.ts new file mode 100644 index 0000000..53e31f9 --- /dev/null +++ b/packages/vulture/src/diff/diff.ts @@ -0,0 +1,149 @@ +import { JSXNode, JSXElement, Attributes, Children, isJSXElement } from '../jsx' +import { Path, PathKey, getPathKey } from './path' + +import { + Patch, + NODE_REPLACE, + ATTRIBUTE_SET, + ATTRIBUTE_REMOVE, + CHILDREN_ADD, + CHILDREN_REMOVE, + CHILDREN_ORDER, +} from './patch' + +/** + * Diffs two JSX nodes returning a list of patches to be applied by the + * renderer to the tree trying to mirror our JSX nodes. + * + * The list of patches is to be executed in definition order, starting at + * index 0, all the way down to the final index. + * + * To learn more about the different patches that might be returned, see the + * `Patch` union type. + */ +export function diff (a: JSXNode, b: JSXNode, path: Path = []): Patch[] { + return diffNode(a, b, path) +} + +/** + * Diffs two JSX nodes. If they are both JSX elements, more complex diffing + * will be done. Otherwise there is just a raw `NodeReplacePatch` performed. + */ +export function diffNode (a: JSXNode, b: JSXNode, path: Path): Patch[] { + if (a === b) return [] + if (isJSXElement(a) && isJSXElement(b)) return diffElement(a, b, path) + return [{ type: NODE_REPLACE, path, node: b }] +} + +/** + * Diffs two JSX elements. If the element names are different, a + * `NodeReplacePatch` is scheduled to be executed. This is because we expect + * elements of different names to have different behaviors. Therefore we can’t + * simply swap out the element name, instead we have to replace the full + * element. + */ +export function diffElement (a: JSXElement, b: JSXElement, path: Path): Patch[] { + if (a === b) + return [] + + if (a.elementName !== b.elementName) + return [{ type: NODE_REPLACE, path, node: b }] + + return [ + ...diffAttributes(a.attributes || {}, b.attributes || {}, path), + ...diffChildren(a.children || [], b.children || [], path), + ] +} + +/** + * Diffs two sets of attributes. This function consists of a fairly simple + * object comparison algorithm. + * + * A few internal attributes (such as `key`) will be ignored. + */ +export function diffAttributes (a: Attributes, b: Attributes, path: Path): Patch[] { + if (a === b) + return [] + + const patches: Patch[] = [] + + for (const name in b) + if (b[name] !== a[name] && name !== 'key') + patches.push({ type: ATTRIBUTE_SET, path, name, value: b[name] }) + + for (const name in a) + if (!b.hasOwnProperty(name)) + patches.push({ type: ATTRIBUTE_REMOVE, path, name }) + + return patches +} + +/** + * Diffs two lists of JSX nodes which are the children of another JSX node. The + * algorithm executes the following steps. Relevant patch types are added for + * reference sake: + * + * 1. Construct an index from the old node list where the node’s key is + * mapped to the the key’s node. + * 2. Loop through all of the new nodes and do the following: + * 1. Get the last node for the same key of this new node. + * 2. If a last node does not exist add the new node (`ChildrenAddPatch`). + * 3. If a last node does exist, diff it with the new node. + * 3. Remove old nodes whose keys do not appear with the new nodes + * (`ChildrenRemovePatch`). + * 4. Order the nodes using the ordered keys from the new nodes + * (`ChildrenOrderPatch`). + */ +// TODO: Performance test this function. There are a lot of iterations we could +// make that do the same thing. We need to find out which iteration is the most +// performant. +export function diffChildren (a: Children, b: Children, path: Path): Patch[] { + if (a === b) + return [] + + let patches: Patch[] = [] + const aKeys: PathKey[] = [] + const bKeys: PathKey[] = [] + + const aIndex = new Map(a.map((node, i) => { + const key = getPathKey(node, i) + aKeys.push(key) + return [key, node] as [PathKey, JSXNode] + })) + + b.forEach((bNode, i) => { + const key = getPathKey(bNode, i) + bKeys.push(key) + + if (aIndex.has(key)) { + const aNode = aIndex.get(key) + aIndex.delete(key) + patches = [...patches, ...diffNode(aNode, bNode, [...path, key])] + } + else { + patches.push({ type: CHILDREN_ADD, path, key, node: bNode }) + } + }) + + for (const [key, aNode] of aIndex) + patches.push({ type: CHILDREN_REMOVE, path, key }) + + let reorder = false + + if (aKeys.length !== bKeys.length) { + reorder = true + } + else { + for (const i in aKeys) { + if (aKeys[i] !== bKeys[i]) { + reorder = true + break + } + } + } + + if (reorder) + patches.push({ type: CHILDREN_ORDER, path, keys: bKeys }) + + return patches +} diff --git a/packages/vulture/src/diff/index.ts b/packages/vulture/src/diff/index.ts new file mode 100644 index 0000000..4418cf4 --- /dev/null +++ b/packages/vulture/src/diff/index.ts @@ -0,0 +1,3 @@ +export { diff } from './diff' +export * from './patch' +export { Path, PathKey, getPathKey } from './path' diff --git a/packages/vulture/src/diff/patch.ts b/packages/vulture/src/diff/patch.ts new file mode 100644 index 0000000..5f1800b --- /dev/null +++ b/packages/vulture/src/diff/patch.ts @@ -0,0 +1,157 @@ +import { JSXNode } from '../jsx' +import { Path, PathKey } from './path' + +/** + * A union type which contains every possible patch to a node tree. + * + * A list of patches must always be executed in order. + * + * Note how there is no patch for a JSX node’s element name. This is because if + * an element name is changed we expect a different behavior from the element. + * Therefore a `NodeReplacePatch` will be fired. + * + * Also note that *every* `Patch` has a constant string `type` property and a + * `Path` type `path` property. This is by design and it allows renderers to at + * least make some assumptions about the general patch type. Having `path` be + * on every patch also allows renderers to validate for all patches at once + * that the node at that path exists. + */ +export type Patch = + NodeReplacePatch + | AttributeSetPatch + | AttributeRemovePatch + | ChildrenAddPatch + | ChildrenRemovePatch + | ChildrenOrderPatch + +/** + * Replaces a node in-place wherever it is. This is the most simple and basic + * patch of all. This is the only patch which can be applied to *all* nodes. + * All the other patches only apply to JSX elements. + * + * Theoretically every diff could just return a single `NodeReplacePatch` at + * the root like so: + * + * ```js + * { + * type: 'NODE_REPLACE', + * path: [], + * node: nextNode, + * } + * ``` + * + * …and get the same tree from the renderer. The reason why we don’t just use + * a node replace is because that operation isn’t very fast. In addition we + * lose all of our event handlers that we’ve attached to the DOM. This is + * undesireable. + * + * Because this is perhaps the slowest patch it is used only when we can’t + * guarantee a more specific patch will be sufficient. + * + * However, sometimes it can still be useful to just straight up replace a + * node. For instance, this patch may be fired to replace one primitive value + * with another, or to replace an element of one name with another. + */ +export type NodeReplacePatch = { + type: NODE_REPLACE, + path: Path, + node: JSXNode, +} + +/** + * Sets a single attribute on an element. May be used to add or replace an + * attribute depending on if the attribute is already there or not. + * + * For some vulture internal attributes, this patch will not be called. + * + * This cannot remove attributes, however. That is what the + * `AttributeRemovePatch` is for. Setting an attribute to null will instead + * keep around an attribute with a null value. + */ +export type AttributeSetPatch = { + type: ATTRIBUTE_SET, + path: Path, + name: string, + value: any, +} + +/** + * Completely removes an attribute from an element. This patch will not just + * set an attribute to null or undefined, but rather completely blow an + * attribute away. + */ +export type AttributeRemovePatch = { + type: ATTRIBUTE_REMOVE, + path: Path, + name: string, +} + +/** + * Adds a child with a new key to an element. The added child’s path will be + * `[...path, key]`. The reason they are seperate is it can be useful for + * renderers to get the path to the parent seperate from the new child’s path. + * + * Note how this patch does not specify the order in which a child should be + * added. The order of all child node’s is specified in a single patch all at + * once. It is recommended that renderers instead of instantly adding children + * directly to the bottom of a list of children, instead the renderer buffer + * the new children and them order them when a `ChildrenOrderPatch` is + * evaluated. + * + * Also note how there is only a `ChildrenAddPatch` and a `ChildrenRemovePatch` + * but no children replace patch. This is because to replace a node you should + * be using `NodeReplacePatch` or another more specific patch. + */ +export type ChildrenAddPatch = { + type: CHILDREN_ADD, + path: Path, + key: PathKey, + node: JSXNode, +} + +/** + * Removes a child node from an element completely. Bye-bye! + */ +export type ChildrenRemovePatch = { + type: CHILDREN_REMOVE, + path: Path, + key: PathKey, +} + +/** + * Specifies the order of an element’s children. This patch should generally be + * placed after any `ChildrenAddPatch`s for any given element. + * + * When this patch is executed it is also assumed that a node exists for all of + * the `keys` and no more node’s exist outside of that list. If this assumption + * turns out to be false, bad things may happen. Therefore it’s important to + * always execute the appropriate `ChildrenAddPatch`s and + * `ChildrenRemovePatch`s first. + */ +export type ChildrenOrderPatch = { + type: CHILDREN_ORDER, + path: Path, + keys: PathKey[], +} + +// Define constants for every patch’s `type` property. In order to do this we +// also will need to define a type as well as a constant string. +// +// Using constants for types has a couple of advantages. +// +// 1. We can name our constants whatever we want. If we wanted to someday +// prefix our constants with say `@vulture/*`, that would be a possibility. +// 2. Constant variable names will be mangled in minification, but strings are +// not mangled. So by defining constants we save bytes. +export type NODE_REPLACE = 'NODE_REPLACE' +export const NODE_REPLACE: NODE_REPLACE = 'NODE_REPLACE' +export type ATTRIBUTE_SET = 'ATTRIBUTE_SET' +export const ATTRIBUTE_SET: ATTRIBUTE_SET = 'ATTRIBUTE_SET' +export type ATTRIBUTE_REMOVE = 'ATTRIBUTE_REMOVE' +export const ATTRIBUTE_REMOVE: ATTRIBUTE_REMOVE = 'ATTRIBUTE_REMOVE' +export type CHILDREN_ADD = 'CHILDREN_ADD' +export const CHILDREN_ADD: CHILDREN_ADD = 'CHILDREN_ADD' +export type CHILDREN_REMOVE = 'CHILDREN_REMOVE' +export const CHILDREN_REMOVE: CHILDREN_REMOVE = 'CHILDREN_REMOVE' +export type CHILDREN_ORDER = 'CHILDREN_ORDER' +export const CHILDREN_ORDER: CHILDREN_ORDER = 'CHILDREN_ORDER' diff --git a/packages/vulture/src/diff/path.ts b/packages/vulture/src/diff/path.ts new file mode 100644 index 0000000..21c2984 --- /dev/null +++ b/packages/vulture/src/diff/path.ts @@ -0,0 +1,41 @@ +import { JSXNode, JSXElement, isJSXElement } from '../jsx' + +/** + * A path used to identify any JSX node across JSX trees and rendered trees. + * It’s comprised of a list of strings which represent nesting. For example, + * the element named “D” would have a path that looks like + * `["0", "hello", "1"]`: + * + * ```jsx + * + * + * + * + * + * + * ``` + * + * Numbers are used for `PathKey`s unless a `key` attribute is defined. + * Renderers do not need to know the implementation details of `Path`. All they + * need to do is use `Path` as just a cache key. + */ +export type Path = PathKey[] + +/** + * A single segment in the `Path` array. + */ +export type PathKey = string + +/** + * Gets the key for a given JSX node. The key will be the node’s key if it is + * an element, otherwise it will be the position of the node as defined by + * `position`. + */ +// TODO: Should this use the full children array? Then we may be able to use +// `elementName` or other hueristics as a key. +export function getPathKey (node: JSXNode, i: number): PathKey { + if (isJSXElement(node) && node.attributes) + return String(node.attributes['id'] || node.attributes['key'] || i) + else + return String(i) +} diff --git a/packages/vulture/src/index.ts b/packages/vulture/src/index.ts new file mode 100644 index 0000000..224d793 --- /dev/null +++ b/packages/vulture/src/index.ts @@ -0,0 +1,4 @@ +export * from './jsx' +export * from './diff' +export * from './renderer' +export * from './async' diff --git a/packages/vulture/src/jsx/README.md b/packages/vulture/src/jsx/README.md new file mode 100644 index 0000000..39f77c6 --- /dev/null +++ b/packages/vulture/src/jsx/README.md @@ -0,0 +1,15 @@ +# Vulture JSX +This module is responsible for operations on JSX. + +JSX is an XML-like extension to ECMAScript authored by [Facebook](http://facebook.github.io/jsx/). It allows developers to use the full expressiveness of JavaScript when constructing their HTML or similar UI markup. In addition having JSX transpile to plain old JavaScript objects gives framework authors extreme flexibility in how the ultimately render their UI. + +## Exports +- `JSXNode` +- `JSXPrimitive` +- `JSXElement` +- `ElementName` +- `Attributes` +- `Children` +- `isJSXElement` +- `isJSXPrimitive` +- `primitiveToString` diff --git a/packages/vulture/src/jsx/__tests__/helpers.js b/packages/vulture/src/jsx/__tests__/helpers.js new file mode 100644 index 0000000..d3d82f1 --- /dev/null +++ b/packages/vulture/src/jsx/__tests__/helpers.js @@ -0,0 +1,78 @@ +import test from 'ava' +import { jsx, isJSXElement, isJSXPrimitive, primitiveToString } from '../helpers' + +test('jsx will create an element with just a name', t => { + t.deepEqual(jsx('a'), { elementName: 'a' }) +}) + +test('jsx will create an element with some attributes only', t => { + t.deepEqual(jsx('a', { b: 1, c: 2 }), { elementName: 'a', attributes: { b: 1, c: 2 } }) +}) + +test('jsx will create an element with some children only', t => { + t.deepEqual(jsx('a', [3, 4]), { elementName: 'a', children: [3, 4] }) +}) + +test('jsx will create an element with attributes and children', t => { + t.deepEqual(jsx('a', { b: 1, c: 2 }, [3, 4]), { elementName: 'a', attributes: { b: 1, c: 2 }, children: [3, 4] }) +}) + +test('isJSXElement will return false for non-objects', t => { + t.false(isJSXElement(undefined)) + t.false(isJSXElement(null)) + t.false(isJSXElement(true)) + t.false(isJSXElement(42)) + t.false(isJSXElement('42')) + t.false(isJSXElement([])) + t.false(isJSXElement(() => {})) +}) + +test('isJSXElement will return false for objects without an element name', t => { + t.false(isJSXElement({})) + t.false(isJSXElement({ a: 1, b: 2 })) + t.false(isJSXElement({ attributes: {}, children: [] })) +}) + +test('isJSXElement will return true for an object with an element name', t => { + t.true(isJSXElement({ elementName: 'a' })) + t.true(isJSXElement({ elementName: 'b', attributes: {} })) + t.true(isJSXElement({ elementName: 'c', children: [] })) + t.true(isJSXElement({ elementName: 'd', attributes: {}, children: [] })) +}) + +test('isJSXPrimitive will return false for non primitives', t => { + t.false(isJSXPrimitive({})) + t.false(isJSXPrimitive({ a: 1, b: 2 })) + t.false(isJSXPrimitive({ elementName: 'a' })) + t.false(isJSXPrimitive([])) + t.false(isJSXPrimitive(() => {})) +}) + +test('isJSXPrimitive will return true for primitives', t => { + t.true(isJSXPrimitive(undefined)) + t.true(isJSXPrimitive(null)) + t.true(isJSXPrimitive(true)) + t.true(isJSXPrimitive(42)) + t.true(isJSXPrimitive('42')) +}) + +test('primitiveToString will make null and undefined blank strings', t => { + t.is(primitiveToString(undefined), '') + t.is(primitiveToString(null), '') +}) + +test('primitiveToString will make booleans blank strings', t => { + t.is(primitiveToString(false), '') + t.is(primitiveToString(true), '') +}) + +test('primitiveToString will turn numbers into strings', t => { + t.is(primitiveToString(42), '42') + t.is(primitiveToString(12), '12') + t.is(primitiveToString(-5), '-5') +}) + +test('primitiveToString will keep strings as strings', t => { + t.is(primitiveToString('hello'), 'hello') + t.is(primitiveToString('world'), 'world') +}) diff --git a/packages/vulture/src/jsx/helpers.ts b/packages/vulture/src/jsx/helpers.ts new file mode 100644 index 0000000..de769dc --- /dev/null +++ b/packages/vulture/src/jsx/helpers.ts @@ -0,0 +1,73 @@ +import { JSXPrimitive, JSXElement, Attributes, Children } from './jsx' + +/** + * A shortcut function for creating JSX elements for those who may not want to + * use a JSX transpiler. This function is similar to hyperscript formats, + * except *much* less complex. + */ +export function jsx(elementName: string): JSXElement +export function jsx(elementName: string, attributes: Attributes): JSXElement +export function jsx(elementName: string, children: Children): JSXElement +export function jsx(elementName: string, attributes: Attributes, children: Children): JSXElement +export function jsx( + elementName: string, + attributesOrChildren?: Attributes | Children, + children?: Children +): JSXElement { + const jsxElement: JSXElement = { elementName } + + if (!attributesOrChildren) { + // noop… + } + else if (Array.isArray(attributesOrChildren)) { + jsxElement.children = attributesOrChildren + } + else { + jsxElement.attributes = attributesOrChildren + if (children) jsxElement.children = children + } + + return jsxElement +} + +/** + * A fast duck-type to check whether any value is a JSX Element. A JSX Element + * must always be an object with *at least* a string `elementName`, so that’s + * all we check for. + */ +export function isJSXElement (value: any): value is JSXElement { + return ( + value != null && + typeof value === 'object' && + typeof value.elementName === 'string' + ) +} + +/** + * Gets whether a value is a `JSXPrimitive` + */ +export function isJSXPrimitive (value: any): value is JSXPrimitive { + return ( + value == null || + typeof value === 'boolean' || + typeof value === 'number' || + typeof value === 'string' + ) +} + +/** + * Transforms a JSX primitive node to a string. + * + * If the node is null or undefined, an empty string is returned. If the node + * is a boolean an empty string is returned (even for `true`!). If the node is + * a number the node gets cast into a string. If the node is a string, nothing + * happens. + * + * These are fairly intuitive rules, all except for `true` returning an empty + * string. + */ +export function primitiveToString (node: JSXPrimitive): string { + if (node == null) return '' + if (typeof node === 'boolean') return '' + return String(node) +} diff --git a/packages/vulture/src/jsx/index.ts b/packages/vulture/src/jsx/index.ts new file mode 100644 index 0000000..a023b8d --- /dev/null +++ b/packages/vulture/src/jsx/index.ts @@ -0,0 +1,2 @@ +export { JSXNode, JSXPrimitive, JSXElement, ElementName, Attributes, Children } from './jsx' +export { jsx, isJSXElement, isJSXPrimitive, primitiveToString } from './helpers' diff --git a/packages/vulture/src/jsx/jsx.ts b/packages/vulture/src/jsx/jsx.ts new file mode 100644 index 0000000..f95bae7 --- /dev/null +++ b/packages/vulture/src/jsx/jsx.ts @@ -0,0 +1,68 @@ +/** + * Anything that can be a JSX node in a JSX tree. All primitive types in + * JavaScript can be considered a JSX Node, and JSX Elements may also be + * considered a node. + */ +export type JSXNode = JSXPrimitive | JSXElement + +/** + * A primitive JSX node. Basically any JSX node that is not an element. Valid + * node primitive values include `null`, `true`, `42`, and `'hello, world'`. + */ +export type JSXPrimitive = undefined | null | boolean | number | string + +/** + * A JSX Element is an XML-like element. It contains an element name, an + * attributes map, and some children nodes. So for example, the following JSX: + * + * ```jsx + * Hello, world! + * ``` + * + * Would become the following JSX object: + * + * ```js + * { + * elementName: 'a', + * attributes: { href: 'https://example.org' }, + * children: ['Hello, world!'], + * } + * ``` + * + * This is a digestible, consice format for JSX consumption by vulture. + * + * The property names for this JSX object were taken directly from the + * [JSX specication][1] for maximum interoperability with other libraries using + * JSX. + * + * If `chilren` is not included in the JSX Element, then the element is a self + * closing element. Similar to `img` tags in HTML that don’t have any children: + * + * ```html + * A cat + * ``` + * + * [1]: http://facebook.github.io/jsx/ + */ +export interface JSXElement { + elementName: ElementName + attributes?: Attributes + children?: Children +} + +// Just a string, pretty boring. +export type ElementName = string + +/** + * The Attributes of a JSX Element is a map of key/value pairs that are the + * same as an XML element’s attributes. + */ +export type Attributes = { + [name: string]: any, +} + +/** + * The children of a JSX element is an array of JSX nodes. Pretty simple, very + * similar to how XML works. + */ +export type Children = JSXNode[] diff --git a/packages/vulture/src/renderer/README.md b/packages/vulture/src/renderer/README.md new file mode 100644 index 0000000..badecbd --- /dev/null +++ b/packages/vulture/src/renderer/README.md @@ -0,0 +1,8 @@ +# Vuture Renderer +In order for JSX to be useful it has to be rendered onto a screen. This module contains the renderer interface which is used to render JSX to whatever target the implementer wants. + +## Exports +- `Renderer` +- `ReducerRenderer` +- `BatchReducerRenderer` +- `PartialRenderer` diff --git a/packages/vulture/src/renderer/index.ts b/packages/vulture/src/renderer/index.ts new file mode 100644 index 0000000..0ca6d47 --- /dev/null +++ b/packages/vulture/src/renderer/index.ts @@ -0,0 +1,3 @@ +export { Renderer } from './renderer' +export { ReducerRenderer, BatchReducerRenderer } from './reducer' +export { PartialRenderer } from './partial' diff --git a/packages/vulture/src/renderer/partial.ts b/packages/vulture/src/renderer/partial.ts new file mode 100644 index 0000000..8ab38da --- /dev/null +++ b/packages/vulture/src/renderer/partial.ts @@ -0,0 +1,22 @@ +import { JSXElement, ElementName, Attributes } from '../jsx' +import { Renderer } from './renderer' + +/** + * Because Vulture abstracts away asynchronous operations, Vulture can also + * easily allow nodes to be streamed. The `PartialRenderer` interface should be + * implemented in order for streaming to be implemented. + * + * Vulture will only stream elements, as its children may finish before the + * element itself. Therefore the partial renderer must be able to render an + * element’s opening tag and closing tag seperately. + * + * The node, opening tag, and closing tag types will each be emitted in the + * observable seperately. It is up to the observable consumer to provide a + * concatenation strategy. The most common `PartialRenderer` is the string + * renderer in which the opening tag and the closing tag types are the same as + * the node’s type, string. + */ +export interface PartialRenderer extends Renderer { + renderOpeningTag (elementName: ElementName, attributes: Attributes): OpeningTag + renderClosingTag (elementName: ElementName): ClosingTag +} diff --git a/packages/vulture/src/renderer/reducer.ts b/packages/vulture/src/renderer/reducer.ts new file mode 100644 index 0000000..1a1e166 --- /dev/null +++ b/packages/vulture/src/renderer/reducer.ts @@ -0,0 +1,50 @@ +import { Patch } from '../diff' +import { Renderer } from './renderer' + +/** + * Any renderer which can take a node and a patch and incremently upgrade the + * node. Renderers that implement this interface are looking for performance + * improvements in rendering. For many rendering libraries it makes more sense + * to make small incremental updates to the render tree instead of creating a + * new render tree on every change. See `vulture/src/diff` for more + * documentation on available patches and diffing. + * + * This operation can be mutative. In other words the following can be true yet + * updates can still happen: + * + * ```js + * assert(reduceNode(node1, patch) === node1) + * ``` + * + * This is opposed to many reduction models that encourage immutability, in + * this case immutability while cute can often negatively impact performance. + * If certain renderers choose to do immutable reductions, that is there + * choice, but this interface *does not* assume immutability. However, this + * interface does also accept that the last node and the next node may not + * always be equal. This could be the case if a `NodeReplacePatch` were to + * replace the root node. + * + * The reducer interface was inspired by Redux, the major difference between + * this interface and Redux is that nodes are not immutable. + */ +export interface ReducerRenderer extends Renderer { + reduceNode (node: Node, patch: Patch): Node +} + +/** + * When `ReducerRenderer` isn’t enough for performance, a renderer may + * implement `BatchReducerRenderer`. This allows the renderer to implement a + * custom batching strategy. Note that a batch reduce may also return a promise + * allowing the batch operation to be asynchronous. + * + * The default `batchReduceNode` implementation is: + * + * ```js + * function batchReduceNode (initialNode, patches) { + * return Promise.resolve(patches.reduce((node, patch) => reduceNode(node, patch), initialNode)) + * } + * ``` + */ +export interface BatchReducerRenderer extends ReducerRenderer { + batchReduceNode (node: Node, patches: Patch[]): Promise +} diff --git a/packages/vulture/src/renderer/renderer.ts b/packages/vulture/src/renderer/renderer.ts new file mode 100644 index 0000000..a30e34f --- /dev/null +++ b/packages/vulture/src/renderer/renderer.ts @@ -0,0 +1,25 @@ +import { JSXNode, ElementName, Attributes, Children } from '../jsx' + +/** + * Any object which has the ability to render a `JSXNode` into any other node + * format. Some common renderers include a renderer for `JSXNode`s to + * `string`s, or `JSXNode`s to HTML DOM `Node`s. + * + * This simple interface is the base for higher level interfaces. For instance, + * a re-render on every update is possible, but it won’t be performant. To get + * a renderer which *reduces* (applies patches from diffed JSX) instead of + * re-renders look at `ReducerRenderer`. + * + * Also note that a `Renderer` takes the synchronous `JSXNode`. This is because + * Vulture abstracts away asynchrity from the renderer author. Whoever is + * writing the renderer must only write preferably pure functions which perform + * appropriate transformations to the synchronous JSX. This is because it is + * much easier to design a performant synchronous API than an asynchronous one. + * + * With this abstracted async approach, Vulture is also able to give the user + * control over scheduling. Using a `setImmediate` or `requestAnimationFrame` + * when available. + */ +export interface Renderer { + renderNode (jsxNode: JSXNode): Node +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..dd578be --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compileOnSave": false, + "compilerOptions": { + "noEmit": true, + "rootDir": "packages", + "target": "es6", + "moduleResolution": "node", + "sourceMap": true, + "declaration": true, + "noImplicitAny": true, + "suppressImplicitAnyIndexErrors": true, + "strictNullChecks": true + }, + "exclude": [ + "node_modules", + "dist", + "build", + "examples" + ] +}