From 178e8011487ed2df079807563d754c076adba7b6 Mon Sep 17 00:00:00 2001 From: Brian M Hunt Date: Thu, 28 Jun 2018 10:59:42 -0400 Subject: [PATCH] 22 tc39 observables (#76) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial support of TC39 Observable subscriptions * tko.observable) Trigger immediately with current with TC39 `{next}`-style subscribe So when a TC39 style `subscribe` call is made, we trigger with the current value immediately, contrasting with the prior behaviour - namely triggering only on changes to the state. https://github.com/tc39/proposal-observable/issues/190 * jsx) fix out-of-order observable insertion * jsx) Fix arrays of new nodes not having context applied * provider/native) Add a provider that has “pre-compiled” binding handler arguments This will be used by the JSX utility. * component/jsx) Support observables for Component jsx templates Now JSX binding arguments are known at the compile-time, so this ought to work: ```js get template () { return
} ``` * jsx) Add support for bindings directly referenced in JSX Sample: ```html ``` … will be given `params={x: alpha}` * provider/native) Fix failure when node doesn’t have `NATIVE_BINDINGS` * component/jsx) re-apply bindings to top-level templates * tko.bind) Fix regression with `onValueUpdate` not working @ctcarton * tko.observable/computed) Have TC39 subscriptions fire immediately for observable/computed * jsx) Fix insert of array appending instead of inserting --- CHANGELOG.md | 2 + package.json | 1 + packages/tko.bind/src/LegacyBindingHandler.js | 21 ++-- packages/tko.bind/src/applyBindings.js | 6 + .../spec/componentBindingBehaviors.js | 39 ++++++ .../src/componentBinding.js | 37 ++++-- .../tko.binding.template/src/templating.js | 2 - packages/tko.computed/src/computed.js | 7 +- .../spec/observableBehaviors.js | 10 ++ .../spec/subscribableBehaviors.js | 8 ++ packages/tko.observable/src/Subscription.js | 32 +++++ packages/tko.observable/src/index.js | 2 +- packages/tko.observable/src/observable.js | 22 ++-- packages/tko.observable/src/subscribable.js | 66 +++++----- packages/tko.provider.native/package.json | 42 +++++++ .../spec/NativeProviderBehaviour.js | 34 +++++ .../tko.provider.native/src/NativeProvider.js | 33 +++++ packages/tko.provider.native/src/index.js | 4 + packages/tko.utils.jsx/src/jsx.js | 117 +++++++++++++++--- packages/tko/src/index.js | 6 +- 20 files changed, 403 insertions(+), 88 deletions(-) create mode 100644 packages/tko.observable/src/Subscription.js create mode 100644 packages/tko.provider.native/package.json create mode 100644 packages/tko.provider.native/spec/NativeProviderBehaviour.js create mode 100644 packages/tko.provider.native/src/NativeProvider.js create mode 100644 packages/tko.provider.native/src/index.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ae3b574..160df4c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,8 @@ For TODO between alpha and release, see https://github.com/knockout/tko/issues/1 * Expose `createViewModel` on Components registered with `Component.register` * Changed `Component.elementName` to `Component.customElementName` and use a kebab-case version of the class name for the custom element name by default * Pass `{element, templateNodes}` to the `Component` constructor as the second parameter of descendants of the `Component` class +* Add support for `` +* Add basic support for `ko.subscribable` as TC39-Observables ## 🚚 Alpha-4a (8 Nov 2017) diff --git a/package.json b/package.json index 1d7ad7ca..5c8e122e 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "scripts": { "prepublish": "yarn build", "test": "lerna exec --concurrency=1 --loglevel=warn -- yarn test", + "fast-test": "lerna exec --concurrency=6 --loglevel=warn -- yarn test", "build": "lerna exec --concurrency=1 --loglevel=warn -- yarn build", "lint": "standard", "repackage": "./tools/common-package-config.js packages/shared.package.json packages/*/package.json" diff --git a/packages/tko.bind/src/LegacyBindingHandler.js b/packages/tko.bind/src/LegacyBindingHandler.js index fd316447..3c94dab8 100644 --- a/packages/tko.bind/src/LegacyBindingHandler.js +++ b/packages/tko.bind/src/LegacyBindingHandler.js @@ -26,6 +26,7 @@ export class LegacyBindingHandler extends BindingHandler { constructor (params) { super(params) const handler = this.handler + this.onError = params.onError if (typeof handler.dispose === 'function') { this.addDisposable(handler) @@ -33,16 +34,18 @@ export class LegacyBindingHandler extends BindingHandler { try { this.initReturn = handler.init && handler.init(...this.legacyArgs) - } catch (e) { params.onError('init', e) } + } catch (e) { + params.onError('init', e) + } + } - if (typeof handler.update === 'function') { - this.computed(() => { - try { - handler.update(...this.legacyArgs) - } catch (e) { - params.onError('update', e) - } - }) + onValueChange () { + const handler = this.handler + if (typeof handler.update !== 'function') { return } + try { + handler.update(...this.legacyArgs) + } catch (e) { + this.onError('update', e) } } diff --git a/packages/tko.bind/src/applyBindings.js b/packages/tko.bind/src/applyBindings.js index af629a1a..012c81e6 100644 --- a/packages/tko.bind/src/applyBindings.js +++ b/packages/tko.bind/src/applyBindings.js @@ -284,6 +284,12 @@ function applyBindingsToNodeInternal (node, sourceBindings, bindingContext, asyn }) ) + if (bindingHandler.onValueChange) { + dependencyDetection.ignore(() => + bindingHandler.computed('onValueChange') + ) + } + // Expose the bindings via domData. allBindingHandlers[key] = bindingHandler diff --git a/packages/tko.binding.component/spec/componentBindingBehaviors.js b/packages/tko.binding.component/spec/componentBindingBehaviors.js index 6aaad161..a73e0b03 100644 --- a/packages/tko.binding.component/spec/componentBindingBehaviors.js +++ b/packages/tko.binding.component/spec/componentBindingBehaviors.js @@ -28,6 +28,10 @@ import { bindings as ifBindings } from 'tko.binding.if' +import { + NATIVE_BINDINGS +} from 'tko.provider.native' + import { bindings as componentBindings } from '../src' @@ -945,6 +949,41 @@ describe('Components: Component binding', function () { children.unshift('rrr') expect(testNode.children[0].innerHTML).toEqual('xrrrabc') }) + + it('inserts and updates observable template', function () { + const t = observable(["abc"]) + class ViewModel extends components.ComponentABC { + get template () { + return t + } + } + ViewModel.register('test-component') + applyBindings(outerViewModel, testNode) + expect(testNode.children[0].innerHTML).toEqual('abc') + + t(["rr", "vv"]) + expect(testNode.children[0].innerHTML).toEqual('rrvv') + }) + + it('gets params from the node', function () { + const x = {v: 'rrr'} + let seen = null + class ViewModel extends components.ComponentABC { + constructor (params) { + super(params) + seen = params + } + + static get template () { + return { elementName: 'name', attributes: {}, children: [] } + } + } + ViewModel.register('test-component') + testNode.children[0][NATIVE_BINDINGS] = {x, y: () => x} + applyBindings(outerViewModel, testNode) + expect(seen.x).toEqual(x) + expect(seen.y()).toEqual(x) + }) }) describe('slots', function () { diff --git a/packages/tko.binding.component/src/componentBinding.js b/packages/tko.binding.component/src/componentBinding.js index 6125f996..b50713c2 100644 --- a/packages/tko.binding.component/src/componentBinding.js +++ b/packages/tko.binding.component/src/componentBinding.js @@ -7,17 +7,21 @@ import { } from 'tko.utils' import { - unwrap + unwrap, isObservable } from 'tko.observable' import { - DescendantBindingHandler, bindingEvent + DescendantBindingHandler, applyBindingsToDescendants } from 'tko.bind' import { - jsxToNode + jsxToNode, maybeJsx } from 'tko.utils.jsx' +import { + NATIVE_BINDINGS +} from 'tko.provider.native' + import {LifeCycle} from 'tko.lifecycle' import registry from 'tko.utils.component' @@ -33,15 +37,25 @@ export default class ComponentBinding extends DescendantBindingHandler { this.computed('computeApplyComponent') } + setDomNodesFromJsx (jsx, element) { + const jsxArray = Array.isArray(jsx) ? jsx : [jsx] + const domNodeChildren = jsxArray.map(jsxToNode) + virtualElements.setDomNodeChildren(element, domNodeChildren) + } + cloneTemplateIntoElement (componentName, template, element) { if (!template) { throw new Error('Component \'' + componentName + '\' has no template') } - const possibleJsxPartial = Array.isArray(template) && template.length - if (possibleJsxPartial && template[0].hasOwnProperty('elementName')) { - virtualElements.setDomNodeChildren(element, template.map(jsxToNode)) - } else if (template.elementName) { - virtualElements.setDomNodeChildren(element, [jsxToNode(template)]) + + if (maybeJsx(template)) { + if (isObservable(template)) { + this.subscribe(template, jsx => { + this.setDomNodesFromJsx(jsx, element) + applyBindingsToDescendants(this.childBindingContext, this.$element) + }) + } + this.setDomNodesFromJsx(unwrap(template), element) } else { const clonedNodesArray = cloneNodes(template) virtualElements.setDomNodeChildren(element, clonedNodesArray) @@ -64,7 +78,8 @@ export default class ComponentBinding extends DescendantBindingHandler { componentName = value } else { componentName = unwrap(value.name) - componentParams = unwrap(value.params) + componentParams = NATIVE_BINDINGS in this.$element + ? this.$element[NATIVE_BINDINGS] : unwrap(value.params) } this.latestComponentName = componentName @@ -117,11 +132,11 @@ export default class ComponentBinding extends DescendantBindingHandler { $componentTemplateNodes: this.originalChildNodes }) - const childBindingContext = this.$context.createChildContext(componentViewModel, /* dataItemAlias */ undefined, ctxExtender) + this.childBindingContext = this.$context.createChildContext(componentViewModel, /* dataItemAlias */ undefined, ctxExtender) this.currentViewModel = componentViewModel const onBinding = this.onBindingComplete.bind(this, componentViewModel) - const applied = this.applyBindingsToDescendants(childBindingContext, onBinding) + this.applyBindingsToDescendants(this.childBindingContext, onBinding) } onBindingComplete (componentViewModel, bindingResult) { diff --git a/packages/tko.binding.template/src/templating.js b/packages/tko.binding.template/src/templating.js index f10c783d..139fec2e 100644 --- a/packages/tko.binding.template/src/templating.js +++ b/packages/tko.binding.template/src/templating.js @@ -281,8 +281,6 @@ export class TemplateBindingHandler extends AsyncBindingHandler { } else { this.bindAnonymousTemplate() } - - this.computed(this.onValueChange.bind(this)) } bindNamedTemplate () { diff --git a/packages/tko.computed/src/computed.js b/packages/tko.computed/src/computed.js index 9503a996..b1f4cd5c 100644 --- a/packages/tko.computed/src/computed.js +++ b/packages/tko.computed/src/computed.js @@ -22,7 +22,8 @@ import { extenders, valuesArePrimitiveAndEqual, observable, - subscribable + subscribable, + LATEST_VALUE } from 'tko.observable' const computedState = createSymbolOrString('_state') @@ -397,6 +398,10 @@ computed.fn = { return state.latestValue }, + get [LATEST_VALUE] () { + return this.peek() + }, + limit (limitFunction) { const state = this[computedState] // Override the limit function with one that delays evaluation as well diff --git a/packages/tko.observable/spec/observableBehaviors.js b/packages/tko.observable/spec/observableBehaviors.js index 6298d804..3c8d77f0 100644 --- a/packages/tko.observable/spec/observableBehaviors.js +++ b/packages/tko.observable/spec/observableBehaviors.js @@ -348,8 +348,18 @@ describe('Observable', function () { expect(myObservable.customFunction1).toBe(customFunction1) expect(myObservable.customFunction2).toBe(customFunction2) }) + + it('immediately emits any value when called with {next: ...}', function () { + const instance = observable(1) + let x + instance.subscribe({next: v => (x = v)}) + expect(x).toEqual(1) + observable(2) + expect(x).toEqual(1) + }) }) + describe('unwrap', function () { it('Should return the supplied value for non-observables', function () { var someObject = { abc: 123 } diff --git a/packages/tko.observable/spec/subscribableBehaviors.js b/packages/tko.observable/spec/subscribableBehaviors.js index d913d9b7..a6e15a56 100644 --- a/packages/tko.observable/spec/subscribableBehaviors.js +++ b/packages/tko.observable/spec/subscribableBehaviors.js @@ -176,4 +176,12 @@ describe('Subscribable', function () { // Issue #2252: make sure .toString method does not throw error expect(new subscribable().toString()).toBe('[object Object]') }) + + it('subscribes with TC39 Observable {next: () =>}', function () { + var instance = new subscribable() + var notifiedValue + instance.subscribe({ next (value) { notifiedValue = value } }) + instance.notifySubscribers(123) + expect(notifiedValue).toEqual(123) + }) }) diff --git a/packages/tko.observable/src/Subscription.js b/packages/tko.observable/src/Subscription.js new file mode 100644 index 00000000..7cc12aac --- /dev/null +++ b/packages/tko.observable/src/Subscription.js @@ -0,0 +1,32 @@ + +import { + removeDisposeCallback, addDisposeCallback +} from 'tko.utils' + + +export default class Subscription { + constructor (target, observer, disposeCallback) { + this._target = target + this._callback = observer.next + this._disposeCallback = disposeCallback + this._isDisposed = false + this._domNodeDisposalCallback = null + } + + dispose () { + if (this._domNodeDisposalCallback) { + removeDisposeCallback(this._node, this._domNodeDisposalCallback) + } + this._isDisposed = true + this._disposeCallback() + } + + disposeWhenNodeIsRemoved (node) { + this._node = node + addDisposeCallback(node, this._domNodeDisposalCallback = this.dispose.bind(this)) + } + + // TC39 Observable API + unsubscribe () { this.dispose() } + get closed () { return this._isDisposed } +} diff --git a/packages/tko.observable/src/index.js b/packages/tko.observable/src/index.js index 8f83367f..ac03cea8 100644 --- a/packages/tko.observable/src/index.js +++ b/packages/tko.observable/src/index.js @@ -9,7 +9,7 @@ export { observable, isObservable, unwrap, peek, isWriteableObservable, isWritableObservable } from './observable' -export { isSubscribable, subscribable } from './subscribable' +export { isSubscribable, subscribable, LATEST_VALUE } from './subscribable' export { observableArray, isObservableArray } from './observableArray' export { trackArrayChanges, arrayChangeEventName } from './observableArray.changeTracking' export { toJS, toJSON } from './mappingHelpers' diff --git a/packages/tko.observable/src/observable.js b/packages/tko.observable/src/observable.js index 60d829fd..474134d2 100644 --- a/packages/tko.observable/src/observable.js +++ b/packages/tko.observable/src/observable.js @@ -3,37 +3,35 @@ // --- // import { - createSymbolOrString, options, overwriteLengthPropertyIfSupported + options, overwriteLengthPropertyIfSupported } from 'tko.utils' import * as dependencyDetection from './dependencyDetection.js' import { deferUpdates } from './defer.js' -import { subscribable, defaultEvent } from './subscribable.js' +import { subscribable, defaultEvent, LATEST_VALUE } from './subscribable.js' import { valuesArePrimitiveAndEqual } from './extenders.js' -var observableLatestValue = createSymbolOrString('_latestValue') - export function observable (initialValue) { function Observable () { if (arguments.length > 0) { // Write // Ignore writes if the value hasn't changed - if (Observable.isDifferent(Observable[observableLatestValue], arguments[0])) { + if (Observable.isDifferent(Observable[LATEST_VALUE], arguments[0])) { Observable.valueWillMutate() - Observable[observableLatestValue] = arguments[0] + Observable[LATEST_VALUE] = arguments[0] Observable.valueHasMutated() } return this // Permits chained assignments } else { // Read dependencyDetection.registerDependency(Observable) // The caller only needs to be notified of changes if they did a "read" operation - return Observable[observableLatestValue] + return Observable[LATEST_VALUE] } } overwriteLengthPropertyIfSupported(Observable, { value: undefined }) - Observable[observableLatestValue] = initialValue + Observable[LATEST_VALUE] = initialValue subscribable.fn.init(Observable) @@ -50,13 +48,13 @@ export function observable (initialValue) { // Define prototype for observables observable.fn = { equalityComparer: valuesArePrimitiveAndEqual, - peek () { return this[observableLatestValue] }, + peek () { return this[LATEST_VALUE] }, valueHasMutated () { - this.notifySubscribers(this[observableLatestValue], 'spectate') - this.notifySubscribers(this[observableLatestValue]) + this.notifySubscribers(this[LATEST_VALUE], 'spectate') + this.notifySubscribers(this[LATEST_VALUE]) }, valueWillMutate () { - this.notifySubscribers(this[observableLatestValue], 'beforeChange') + this.notifySubscribers(this[LATEST_VALUE], 'beforeChange') }, // Some observables may not always be writeable, notably computeds. diff --git a/packages/tko.observable/src/subscribable.js b/packages/tko.observable/src/subscribable.js index ae8926e8..99917986 100644 --- a/packages/tko.observable/src/subscribable.js +++ b/packages/tko.observable/src/subscribable.js @@ -1,37 +1,18 @@ /* eslint no-cond-assign: 0 */ import { - arrayRemoveItem, objectForEach, options, removeDisposeCallback, - addDisposeCallback + arrayRemoveItem, objectForEach, options } from 'tko.utils' +import Subscription from './Subscription' import { SUBSCRIBABLE_SYM } from './subscribableSymbol' -export { isSubscribable } from './subscribableSymbol' import { applyExtenders } from './extenders.js' import * as dependencyDetection from './dependencyDetection.js' +export { isSubscribable } from './subscribableSymbol' - -export function subscription (target, callback, disposeCallback) { - this._target = target - this._callback = callback - this._disposeCallback = disposeCallback - this._isDisposed = false - this._domNodeDisposalCallback = null -} - -Object.assign(subscription.prototype, { - dispose () { - if (this._domNodeDisposalCallback) { - removeDisposeCallback(this._node, this._domNodeDisposalCallback) - } - this._isDisposed = true - this._disposeCallback() - }, - - disposeWhenNodeIsRemoved (node) { - this._node = node - addDisposeCallback(node, this._domNodeDisposalCallback = this.dispose.bind(this)) - } -}) +// Descendants may have a LATEST_VALUE, which if present +// causes TC39 subscriptions to emit the latest value when +// subscribed. +export const LATEST_VALUE = Symbol('Knockout latest value') export function subscribable () { Object.setPrototypeOf(this, ko_subscribable_fn) @@ -42,6 +23,7 @@ export var defaultEvent = 'change' var ko_subscribable_fn = { [SUBSCRIBABLE_SYM]: true, + [Symbol.observable] () { return this }, init (instance) { instance._subscriptions = { change: [] } @@ -49,26 +31,36 @@ var ko_subscribable_fn = { }, subscribe (callback, callbackTarget, event) { - var self = this + // TC39 proposed standard Observable { next: () => ... } + const isTC39Callback = typeof callback === 'object' && callback.next event = event || defaultEvent - var boundCallback = callbackTarget ? callback.bind(callbackTarget) : callback + const observer = isTC39Callback ? callback : { + next: callbackTarget ? callback.bind(callbackTarget) : callback + } - var subscriptionInstance = new subscription(self, boundCallback, function () { - arrayRemoveItem(self._subscriptions[event], subscriptionInstance) - if (self.afterSubscriptionRemove) { - self.afterSubscriptionRemove(event) + const subscriptionInstance = new Subscription(this, observer, () => { + arrayRemoveItem(this._subscriptions[event], subscriptionInstance) + if (this.afterSubscriptionRemove) { + this.afterSubscriptionRemove(event) } }) - if (self.beforeSubscriptionAdd) { - self.beforeSubscriptionAdd(event) + if (this.beforeSubscriptionAdd) { + this.beforeSubscriptionAdd(event) } - if (!self._subscriptions[event]) { - self._subscriptions[event] = [] + if (!this._subscriptions[event]) { + this._subscriptions[event] = [] + } + this._subscriptions[event].push(subscriptionInstance) + + // Have TC39 `subscribe` immediately emit. + // https://github.com/tc39/proposal-observable/issues/190 + + if (isTC39Callback && LATEST_VALUE in this) { + observer.next(this[LATEST_VALUE]) } - self._subscriptions[event].push(subscriptionInstance) return subscriptionInstance }, diff --git a/packages/tko.provider.native/package.json b/packages/tko.provider.native/package.json new file mode 100644 index 00000000..77c3215d --- /dev/null +++ b/packages/tko.provider.native/package.json @@ -0,0 +1,42 @@ +{ + "name": "tko.provider.native", + "version": "4.0.0-alpha4", + "description": "Link binding handlers whose value is already attached to the node", + "module": "dist/tko.provider.native.js", + "files": [ + "dist/" + ], + "license": "MIT", + "dependencies": { + "tko.provider": "^4.0.0-alpha4", + "tslib": "^1.8.0" + }, + "karma": { + "frameworks": [ + "mocha", + "chai" + ] + }, + "__about__shared.package.json": "These properties are copied into all packages/*/package.json. Run `yarn repackage`", + "standard": { + "globals": [ + "assert", + "jasmine", + "beforeEach", + "before", + "after", + "afterEach", + "it", + "iit", + "xit", + "expect", + "describe", + "ddescribe" + ] + }, + "scripts": { + "test": "karma start ../../karma.conf.js --once", + "build": "rollup -c ../../rollup.config.js", + "watch": "karma start ../../karma.conf.js" + } +} diff --git a/packages/tko.provider.native/spec/NativeProviderBehaviour.js b/packages/tko.provider.native/spec/NativeProviderBehaviour.js new file mode 100644 index 00000000..8c0403d7 --- /dev/null +++ b/packages/tko.provider.native/spec/NativeProviderBehaviour.js @@ -0,0 +1,34 @@ + +import { + NativeProvider, NATIVE_BINDINGS +} from '../src' + +describe('Native Provider Behaviour', function () { + it('returns native bindings', function () { + const p = new NativeProvider() + const div = document.createElement('div') + const attr = {'ko-thing': {}} + div[NATIVE_BINDINGS] = attr + assert.ok(p.nodeHasBindings(div), true) + const accessors = p.getBindingAccessors(div) + assert.equal(Object.keys(accessors).length, 1) + assert.strictEqual(accessors['thing'](), attr['ko-thing']) + }) + + it('has no bindings when no `ko-*` is present', function () { + const p = new NativeProvider() + const div = document.createElement('div') + const attr = {'thing': {}} + div[NATIVE_BINDINGS] = attr + assert.notOk(p.nodeHasBindings(div), false) + assert.deepEqual(p.getBindingAccessors(div), {}) + }) + + it('ignores nodes w/o the symbol', function () { + const p = new NativeProvider() + const div = document.createElement('div') + assert.notOk(p.nodeHasBindings(div), false) + assert.deepEqual(p.getBindingAccessors(div), {}) + }) + +}) diff --git a/packages/tko.provider.native/src/NativeProvider.js b/packages/tko.provider.native/src/NativeProvider.js new file mode 100644 index 00000000..cc52bbe8 --- /dev/null +++ b/packages/tko.provider.native/src/NativeProvider.js @@ -0,0 +1,33 @@ + +import { + Provider +} from 'tko.provider' + +export const NATIVE_BINDINGS = Symbol('Knockout native bindings') + +/** + * Retrieve the binding accessors that are already attached to + * a node under the `NATIVE_BINDINGS` symbol. + * + * Used by the jsxToNode function. + */ +export default class NativeProvider extends Provider { + get FOR_NODE_TYPES () { return [ 1 ] } // document.ELEMENT_NODE + + nodeHasBindings (node) { + return Object.keys(node[NATIVE_BINDINGS] || {}) + .some(key => key.startsWith('ko-')) + } + + /** + * Return as valueAccessor function all the entries matching `ko-*` + * @param {HTMLElement} node + */ + getBindingAccessors (node) { + return Object.assign({}, + ...Object.entries(node[NATIVE_BINDINGS] || {}) + .filter(([name, value]) => name.startsWith('ko-')) + .map(([name, value]) => ({[name.replace(/^ko-/, '')]: () => value})) + ) + } +} diff --git a/packages/tko.provider.native/src/index.js b/packages/tko.provider.native/src/index.js new file mode 100644 index 00000000..ce9a70f6 --- /dev/null +++ b/packages/tko.provider.native/src/index.js @@ -0,0 +1,4 @@ +export { + default as NativeProvider, + NATIVE_BINDINGS +} from './NativeProvider' diff --git a/packages/tko.utils.jsx/src/jsx.js b/packages/tko.utils.jsx/src/jsx.js index d19a3471..d136ceac 100644 --- a/packages/tko.utils.jsx/src/jsx.js +++ b/packages/tko.utils.jsx/src/jsx.js @@ -11,6 +11,36 @@ import { contextFor, applyBindings } from 'tko.bind' +import { + NATIVE_BINDINGS +} from 'tko.provider.native' + + +/** + * + * @param {any} possibleJsx Test whether this value is JSX. + * + * True for + * { elementName } + * [{elementName}] + * observable({elementName} | []) + * + * Any observable will return truthy if its value is an array that doesn't + * contain HTML elements. Template nodes should not be observable unless they + * are JSX. + * + * There's a bit of guesswork here that we could nail down with more test cases. + */ +export function maybeJsx (possibleJsx) { + if (isObservable(possibleJsx)) { return true } + const value = unwrap(possibleJsx) + if (!value) { return false } + if (value.elementName) { return true } + if (!Array.isArray(value) || !value.length) { return false } + if (value[0] instanceof window.Node) { return false } + return true +} + /** * Use a JSX transpilation of the format created by babel-plugin-transform-jsx * @param {Object} jsx An object of the form @@ -20,6 +50,10 @@ import { * } */ export function jsxToNode (jsx) { + if (typeof jsx === 'string') { + return document.createTextNode(jsx) + } + const node = document.createElement(jsx.elementName) const subscriptions = [] @@ -44,15 +78,41 @@ export function jsxToNode (jsx) { return node } -function appendChildOrChildren (possibleTemplateElement, nodeToAppend) { - if (Array.isArray(nodeToAppend)) { - for (const node of nodeToAppend) { +function getInsertTarget (possibleTemplateElement) { + return 'content' in possibleTemplateElement + ? possibleTemplateElement.content : possibleTemplateElement +} + +/** + * + * @param {HTMLElement|HTMLTemplateElement} possibleTemplateElement + * @param {Node} toAppend + */ +function appendChildOrChildren (possibleTemplateElement, toAppend) { + if (Array.isArray(toAppend)) { + for (const node of toAppend) { appendChildOrChildren(possibleTemplateElement, node) } - } else if ('content' in possibleTemplateElement) { - possibleTemplateElement.content.appendChild(nodeToAppend) } else { - possibleTemplateElement.appendChild(nodeToAppend) + getInsertTarget(possibleTemplateElement).appendChild(toAppend) + } +} + +/** + * + * @param {HTMLElement|HTMLTemplateElement} possibleTemplateElement + * @param {Node} toAppend + * @param {Node} beforeNode + */ +function insertChildOrChildren (possibleTemplateElement, toAppend, beforeNode) { + if (!beforeNode.parentNode) { return } + + if (Array.isArray(toAppend)) { + for (const node of toAppend) { + insertChildOrChildren(possibleTemplateElement, node, beforeNode) + } + } else { + getInsertTarget(possibleTemplateElement).insertBefore(toAppend, beforeNode) } } @@ -78,6 +138,18 @@ function updateChildren (node, children, subscriptions) { } } +/** + * + * @param {*} node + * @param {*} name + * @param {*} value + */ +function setNodeAttribute (node, name, value) { + const nodeJsxAttrs = node[NATIVE_BINDINGS] || (node[NATIVE_BINDINGS] = {}) + nodeJsxAttrs[name] = value + if (typeof value === 'string') { node.setAttribute(name, value) } +} + /** * * @param {HTMLElement} node @@ -95,13 +167,13 @@ function updateAttributes (node, attributes, subscriptions) { if (attr === undefined) { node.removeAttribute(name) } else { - node.setAttribute(name, attr) + setNodeAttribute(node, name, attr) } })) } const unwrappedValue = unwrap(value) if (unwrappedValue !== undefined) { - node.setAttribute(name, unwrappedValue) + setNodeAttribute(node, name, unwrappedValue) } } } @@ -117,17 +189,34 @@ function updateAttributes (node, attributes, subscriptions) { function replaceNodeOrNodes (newJsx, toReplace, parentNode) { const newNodeOrNodes = convertJsxChildToDom(newJsx) const $context = contextFor(toReplace) + const firstNodeToReplace = Array.isArray(toReplace) + ? toReplace[0] || null : toReplace + + insertChildOrChildren(parentNode, newNodeOrNodes, firstNodeToReplace) if (Array.isArray(toReplace)) { for (const node of toReplace) { removeNode(node) } } else { removeNode(toReplace) } - appendChildOrChildren(parentNode, newNodeOrNodes) - if ($context) { applyBindings($context, newNodeOrNodes) } + + if ($context) { + if (Array.isArray(newNodeOrNodes)) { + for (const node of newNodeOrNodes) { + applyBindings($context, node) + } + } else { + applyBindings($context, newNodeOrNodes) + } + } return newNodeOrNodes } +/** + * + * @param {HTMLElement} node + * @param {jsx|Array} child + */ function monitorObservableChild (node, child) { const jsx = unwrap(child) let toReplace = convertJsxChildToDom(jsx) @@ -146,8 +235,8 @@ function monitorObservableChild (node, child) { * @return {Array|Comment|HTMLElement} */ function convertJsxChildToDom (child) { - return typeof child === 'string' ? document.createTextNode(child) - : Array.isArray(child) ? child.map(convertJsxChildToDom) - : child ? jsxToNode(child) - : document.createComment('[jsx placeholder]') + return Array.isArray(child) + ? child.map(convertJsxChildToDom) + : child ? jsxToNode(child) + : document.createComment('[jsx placeholder]') } diff --git a/packages/tko/src/index.js b/packages/tko/src/index.js index ce0d79df..d479ca09 100644 --- a/packages/tko/src/index.js +++ b/packages/tko/src/index.js @@ -8,6 +8,9 @@ import { MultiProvider } from 'tko.provider.multi' import { TextMustacheProvider, AttributeMustacheProvider } from 'tko.provider.mustache' +import { + NativeProvider +} from 'tko.provider.native' import { bindings as coreBindings } from 'tko.binding.core' import { bindings as templateBindings } from 'tko.binding.template' @@ -28,7 +31,8 @@ const builder = new Builder({ new ComponentProvider(), new DataBindProvider(), new VirtualProvider(), - new AttributeProvider() + new AttributeProvider(), + new NativeProvider() ] }), bindings: [