From f71cc32874d6b657fe936caeb8501d55dded4cd2 Mon Sep 17 00:00:00 2001 From: Brian M Hunt Date: Sat, 23 Jun 2018 07:25:48 -0400 Subject: [PATCH 01/12] Initial support of TC39 Observable subscriptions --- .../spec/subscribableBehaviors.js | 8 ++++ packages/tko.observable/src/Subscription.js | 32 +++++++++++++++ packages/tko.observable/src/observable.js | 7 +++- packages/tko.observable/src/subscribable.js | 41 +++++-------------- 4 files changed, 56 insertions(+), 32 deletions(-) create mode 100644 packages/tko.observable/src/Subscription.js diff --git a/packages/tko.observable/spec/subscribableBehaviors.js b/packages/tko.observable/spec/subscribableBehaviors.js index d913d9b76..a6e15a565 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 000000000..7cc12aac9 --- /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/observable.js b/packages/tko.observable/src/observable.js index 60d829fdc..728dd10ee 100644 --- a/packages/tko.observable/src/observable.js +++ b/packages/tko.observable/src/observable.js @@ -3,7 +3,7 @@ // --- // import { - createSymbolOrString, options, overwriteLengthPropertyIfSupported + options, overwriteLengthPropertyIfSupported } from 'tko.utils' import * as dependencyDetection from './dependencyDetection.js' @@ -11,7 +11,7 @@ import { deferUpdates } from './defer.js' import { subscribable, defaultEvent } from './subscribable.js' import { valuesArePrimitiveAndEqual } from './extenders.js' -var observableLatestValue = createSymbolOrString('_latestValue') +var observableLatestValue = Symbol('_latestValue') export function observable (initialValue) { function Observable () { @@ -58,6 +58,9 @@ observable.fn = { valueWillMutate () { this.notifySubscribers(this[observableLatestValue], 'beforeChange') }, + // subscribe (callback, callbackTarget, event) { + // https://github.com/tc39/proposal-observable/issues/190 + // } // Some observables may not always be writeable, notably computeds. isWriteable: true diff --git a/packages/tko.observable/src/subscribable.js b/packages/tko.observable/src/subscribable.js index ae8926e8d..3507ff297 100644 --- a/packages/tko.observable/src/subscribable.js +++ b/packages/tko.observable/src/subscribable.js @@ -1,37 +1,13 @@ /* 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 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)) - } -}) +export { isSubscribable } from './subscribableSymbol' export function subscribable () { Object.setPrototypeOf(this, ko_subscribable_fn) @@ -42,6 +18,7 @@ export var defaultEvent = 'change' var ko_subscribable_fn = { [SUBSCRIBABLE_SYM]: true, + [Symbol.observable] () { return this }, init (instance) { instance._subscriptions = { change: [] } @@ -49,12 +26,16 @@ var ko_subscribable_fn = { }, subscribe (callback, callbackTarget, event) { - var self = this + const 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 () { + const subscriptionInstance = new Subscription(self, observer, function () { arrayRemoveItem(self._subscriptions[event], subscriptionInstance) if (self.afterSubscriptionRemove) { self.afterSubscriptionRemove(event) From 01eac297ad8c408895439eb9665c5c13069d798e Mon Sep 17 00:00:00 2001 From: Brian M Hunt Date: Sun, 24 Jun 2018 14:13:57 -0400 Subject: [PATCH 02/12] 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 --- CHANGELOG.md | 2 ++ packages/tko.observable/spec/observableBehaviors.js | 10 ++++++++++ packages/tko.observable/src/observable.js | 11 +++++++++-- 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ae3b5749..160df4c04 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/packages/tko.observable/spec/observableBehaviors.js b/packages/tko.observable/spec/observableBehaviors.js index 6298d8042..3c8d77f07 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/src/observable.js b/packages/tko.observable/src/observable.js index 728dd10ee..f8c681119 100644 --- a/packages/tko.observable/src/observable.js +++ b/packages/tko.observable/src/observable.js @@ -58,9 +58,16 @@ observable.fn = { valueWillMutate () { this.notifySubscribers(this[observableLatestValue], 'beforeChange') }, - // subscribe (callback, callbackTarget, event) { + + // Note that TC39 `subscribe` ought to immediately emit. // https://github.com/tc39/proposal-observable/issues/190 - // } + subscribe (callback, callbackTarget, event) { + const isTC39Callback = typeof callback === 'object' && callback.next + if (isTC39Callback) { + callback.next(this()) + } + return subscribable.fn.subscribe.call(this, callback, callbackTarget, event) + }, // Some observables may not always be writeable, notably computeds. isWriteable: true From ac895f371d8fda82203ec693100cb1fb705ab5be Mon Sep 17 00:00:00 2001 From: Brian M Hunt Date: Tue, 26 Jun 2018 13:35:43 -0400 Subject: [PATCH 03/12] jsx) fix out-of-order observable insertion --- packages/tko.utils.jsx/src/jsx.js | 46 ++++++++++++++++++++++++++----- 1 file changed, 39 insertions(+), 7 deletions(-) diff --git a/packages/tko.utils.jsx/src/jsx.js b/packages/tko.utils.jsx/src/jsx.js index d19a34714..52a560555 100644 --- a/packages/tko.utils.jsx/src/jsx.js +++ b/packages/tko.utils.jsx/src/jsx.js @@ -44,15 +44,39 @@ 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 (Array.isArray(toAppend)) { + for (const node of toAppend) { + appendChildOrChildren(possibleTemplateElement, node) + } + } else { + getInsertTarget(possibleTemplateElement).insertBefore(toAppend, beforeNode) } } @@ -117,17 +141,25 @@ 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) } return newNodeOrNodes } +/** + * + * @param {HTMLElement} node + * @param {jsx|Array} child + */ function monitorObservableChild (node, child) { const jsx = unwrap(child) let toReplace = convertJsxChildToDom(jsx) From 8f76188fa5f7a2216b28354b7735a01b14390664 Mon Sep 17 00:00:00 2001 From: Brian M Hunt Date: Wed, 27 Jun 2018 10:44:04 -0400 Subject: [PATCH 04/12] jsx) Fix arrays of new nodes not having context applied --- packages/tko.utils.jsx/src/jsx.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/tko.utils.jsx/src/jsx.js b/packages/tko.utils.jsx/src/jsx.js index 52a560555..92bc140c2 100644 --- a/packages/tko.utils.jsx/src/jsx.js +++ b/packages/tko.utils.jsx/src/jsx.js @@ -71,6 +71,8 @@ function appendChildOrChildren (possibleTemplateElement, toAppend) { * @param {Node} beforeNode */ function insertChildOrChildren (possibleTemplateElement, toAppend, beforeNode) { + if (!beforeNode.parentNode) { return } + if (Array.isArray(toAppend)) { for (const node of toAppend) { appendChildOrChildren(possibleTemplateElement, node) @@ -151,7 +153,15 @@ function replaceNodeOrNodes (newJsx, toReplace, parentNode) { } else { removeNode(toReplace) } - 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 } From fd5c1cd1addcd71edf5df1e790a24b4a5b262aca Mon Sep 17 00:00:00 2001 From: Brian M Hunt Date: Wed, 27 Jun 2018 18:37:32 -0400 Subject: [PATCH 05/12] =?UTF-8?q?provider/native)=20Add=20a=20provider=20t?= =?UTF-8?q?hat=20has=20=E2=80=9Cpre-compiled=E2=80=9D=20binding=20handler?= =?UTF-8?q?=20arguments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This will be used by the JSX utility. --- packages/tko.provider.native/package.json | 42 +++++++++++++++++++ .../spec/NativeProviderBehaviour.js | 22 ++++++++++ .../tko.provider.native/src/NativeProvider.js | 24 +++++++++++ packages/tko.provider.native/src/index.js | 4 ++ 4 files changed, 92 insertions(+) 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/packages/tko.provider.native/package.json b/packages/tko.provider.native/package.json new file mode 100644 index 000000000..77c3215d6 --- /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 000000000..292e6c3e8 --- /dev/null +++ b/packages/tko.provider.native/spec/NativeProviderBehaviour.js @@ -0,0 +1,22 @@ + +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) + assert.strictEqual(p.getBindingAccessors(div), attr) + }) + + it('skips nodes w/o the symbol', function () { + const p = new NativeProvider() + const div = document.createElement('div') + assert.notOk(p.nodeHasBindings(div), true) + assert.equal(p.getBindingAccessors(div), undefined) + }) +}) diff --git a/packages/tko.provider.native/src/NativeProvider.js b/packages/tko.provider.native/src/NativeProvider.js new file mode 100644 index 000000000..1d588afc4 --- /dev/null +++ b/packages/tko.provider.native/src/NativeProvider.js @@ -0,0 +1,24 @@ + +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 JsxProvider extends Provider { + get FOR_NODE_TYPES () { return [ 1 ] } // document.ELEMENT_NODE + + nodeHasBindings (node) { + return node[NATIVE_BINDINGS] + } + + getBindingAccessors (node, context) { + return node[NATIVE_BINDINGS] + } +} diff --git a/packages/tko.provider.native/src/index.js b/packages/tko.provider.native/src/index.js new file mode 100644 index 000000000..ce9a70f61 --- /dev/null +++ b/packages/tko.provider.native/src/index.js @@ -0,0 +1,4 @@ +export { + default as NativeProvider, + NATIVE_BINDINGS +} from './NativeProvider' From 6397807be85498530697b1148530b02df1b22e86 Mon Sep 17 00:00:00 2001 From: Brian M Hunt Date: Wed, 27 Jun 2018 18:39:47 -0400 Subject: [PATCH 06/12] 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
} ``` --- .../src/componentBinding.js | 23 ++++++---- packages/tko.utils.jsx/src/jsx.js | 45 ++++++++++++++++++- packages/tko/src/index.js | 6 ++- 3 files changed, 63 insertions(+), 11 deletions(-) diff --git a/packages/tko.binding.component/src/componentBinding.js b/packages/tko.binding.component/src/componentBinding.js index 6125f9968..55b690164 100644 --- a/packages/tko.binding.component/src/componentBinding.js +++ b/packages/tko.binding.component/src/componentBinding.js @@ -7,15 +7,15 @@ import { } from 'tko.utils' import { - unwrap + unwrap, isObservable } from 'tko.observable' import { - DescendantBindingHandler, bindingEvent + DescendantBindingHandler } from 'tko.bind' import { - jsxToNode + jsxToNode, maybeJsx } from 'tko.utils.jsx' import {LifeCycle} from 'tko.lifecycle' @@ -33,15 +33,22 @@ 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)) + this.setDomNodesFromJsx(unwrap(template), element) + } } else { const clonedNodesArray = cloneNodes(template) virtualElements.setDomNodeChildren(element, clonedNodesArray) diff --git a/packages/tko.utils.jsx/src/jsx.js b/packages/tko.utils.jsx/src/jsx.js index 92bc140c2..e1834ea68 100644 --- a/packages/tko.utils.jsx/src/jsx.js +++ b/packages/tko.utils.jsx/src/jsx.js @@ -11,6 +11,31 @@ 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, + * since it may in future be JSX. + */ +export function maybeJsx (possibleJsx) { + const value = unwrap(possibleJsx) + if (!value) { return false } + if (value.elementName) { return true } + if (value.length && value[0].elementName) { return true } + return false +} + /** * Use a JSX transpilation of the format created by babel-plugin-transform-jsx * @param {Object} jsx An object of the form @@ -104,6 +129,21 @@ function updateChildren (node, children, subscriptions) { } } +/** + * + * @param {*} node + * @param {*} name + * @param {*} value + */ +function setNodeAttribute (node, name, value) { + if (name.startsWith('ko-')) { + const nodeJsxAttrs = node[NATIVE_BINDINGS] || (node[NATIVE_BINDINGS] = {}) + nodeJsxAttrs[name.replace(/^ko-/, '')] = () => value + } else { + node.setAttribute(name, value) + } +} + /** * * @param {HTMLElement} node @@ -121,13 +161,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) } } } @@ -153,6 +193,7 @@ function replaceNodeOrNodes (newJsx, toReplace, parentNode) { } else { removeNode(toReplace) } + if ($context) { if (Array.isArray(newNodeOrNodes)) { for (const node of newNodeOrNodes) { diff --git a/packages/tko/src/index.js b/packages/tko/src/index.js index ce0d79df7..d479ca094 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: [ From 929454ff052e6caf1604b86ba6ffc33354f8b391 Mon Sep 17 00:00:00 2001 From: Brian M Hunt Date: Wed, 27 Jun 2018 21:30:04 -0400 Subject: [PATCH 07/12] jsx) Add support for bindings directly referenced in JSX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sample: ```html ``` … will be given `params={x: alpha}` --- .../spec/componentBindingBehaviors.js | 24 +++++++++++++++++++ .../src/componentBinding.js | 9 +++++-- .../spec/NativeProviderBehaviour.js | 12 ++++++---- .../tko.provider.native/src/NativeProvider.js | 15 +++++++++--- packages/tko.utils.jsx/src/jsx.js | 9 +++---- 5 files changed, 54 insertions(+), 15 deletions(-) diff --git a/packages/tko.binding.component/spec/componentBindingBehaviors.js b/packages/tko.binding.component/spec/componentBindingBehaviors.js index 6aaad161a..56582a129 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,26 @@ describe('Components: Component binding', function () { children.unshift('rrr') expect(testNode.children[0].innerHTML).toEqual('xrrrabc') }) + + 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 55b690164..ecdbaccf5 100644 --- a/packages/tko.binding.component/src/componentBinding.js +++ b/packages/tko.binding.component/src/componentBinding.js @@ -18,6 +18,10 @@ import { jsxToNode, maybeJsx } from 'tko.utils.jsx' +import { + NATIVE_BINDINGS +} from 'tko.provider.native' + import {LifeCycle} from 'tko.lifecycle' import registry from 'tko.utils.component' @@ -47,8 +51,8 @@ export default class ComponentBinding extends DescendantBindingHandler { if (maybeJsx(template)) { if (isObservable(template)) { this.subscribe(template, jsx => this.setDomNodesFromJsx(jsx, element)) - this.setDomNodesFromJsx(unwrap(template), element) } + this.setDomNodesFromJsx(unwrap(template), element) } else { const clonedNodesArray = cloneNodes(template) virtualElements.setDomNodeChildren(element, clonedNodesArray) @@ -71,7 +75,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 diff --git a/packages/tko.provider.native/spec/NativeProviderBehaviour.js b/packages/tko.provider.native/spec/NativeProviderBehaviour.js index 292e6c3e8..c14e5e4a3 100644 --- a/packages/tko.provider.native/spec/NativeProviderBehaviour.js +++ b/packages/tko.provider.native/spec/NativeProviderBehaviour.js @@ -10,13 +10,17 @@ describe('Native Provider Behaviour', function () { const attr = {'ko-thing': {}} div[NATIVE_BINDINGS] = attr assert.ok(p.nodeHasBindings(div), true) - assert.strictEqual(p.getBindingAccessors(div), attr) + const accessors = p.getBindingAccessors(div) + assert.equal(Object.keys(accessors).length, 1) + assert.strictEqual(accessors['thing'](), attr['ko-thing']) }) - it('skips nodes w/o the symbol', function () { + it('ignores nodes w/o the symbol', function () { const p = new NativeProvider() const div = document.createElement('div') - assert.notOk(p.nodeHasBindings(div), true) - assert.equal(p.getBindingAccessors(div), undefined) + const attr = {'thing': {}} + div[NATIVE_BINDINGS] = attr + 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 index 1d588afc4..de6048d2c 100644 --- a/packages/tko.provider.native/src/NativeProvider.js +++ b/packages/tko.provider.native/src/NativeProvider.js @@ -15,10 +15,19 @@ export default class JsxProvider extends Provider { get FOR_NODE_TYPES () { return [ 1 ] } // document.ELEMENT_NODE nodeHasBindings (node) { - return node[NATIVE_BINDINGS] + return Object.keys(node[NATIVE_BINDINGS]) + .some(key => key.startsWith('ko-')) } - getBindingAccessors (node, context) { - return node[NATIVE_BINDINGS] + /** + * 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.utils.jsx/src/jsx.js b/packages/tko.utils.jsx/src/jsx.js index e1834ea68..4e281e35e 100644 --- a/packages/tko.utils.jsx/src/jsx.js +++ b/packages/tko.utils.jsx/src/jsx.js @@ -136,12 +136,9 @@ function updateChildren (node, children, subscriptions) { * @param {*} value */ function setNodeAttribute (node, name, value) { - if (name.startsWith('ko-')) { - const nodeJsxAttrs = node[NATIVE_BINDINGS] || (node[NATIVE_BINDINGS] = {}) - nodeJsxAttrs[name.replace(/^ko-/, '')] = () => value - } else { - node.setAttribute(name, value) - } + const nodeJsxAttrs = node[NATIVE_BINDINGS] || (node[NATIVE_BINDINGS] = {}) + nodeJsxAttrs[name] = value + if (typeof value === 'string') { node.setAttribute(name, value) } } /** From d83fd29681d94a330af9815e6304ee0f7b06db4c Mon Sep 17 00:00:00 2001 From: Brian M Hunt Date: Wed, 27 Jun 2018 21:34:56 -0400 Subject: [PATCH 08/12] =?UTF-8?q?provider/native)=20Fix=20failure=20when?= =?UTF-8?q?=20node=20doesn=E2=80=99t=20have=20`NATIVE=5FBINDINGS`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spec/NativeProviderBehaviour.js | 10 +++++++++- packages/tko.provider.native/src/NativeProvider.js | 6 +++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/tko.provider.native/spec/NativeProviderBehaviour.js b/packages/tko.provider.native/spec/NativeProviderBehaviour.js index c14e5e4a3..8c0403d75 100644 --- a/packages/tko.provider.native/spec/NativeProviderBehaviour.js +++ b/packages/tko.provider.native/spec/NativeProviderBehaviour.js @@ -15,7 +15,7 @@ describe('Native Provider Behaviour', function () { assert.strictEqual(accessors['thing'](), attr['ko-thing']) }) - it('ignores nodes w/o the symbol', function () { + it('has no bindings when no `ko-*` is present', function () { const p = new NativeProvider() const div = document.createElement('div') const attr = {'thing': {}} @@ -23,4 +23,12 @@ describe('Native Provider Behaviour', function () { 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 index de6048d2c..cc52bbe8e 100644 --- a/packages/tko.provider.native/src/NativeProvider.js +++ b/packages/tko.provider.native/src/NativeProvider.js @@ -11,11 +11,11 @@ export const NATIVE_BINDINGS = Symbol('Knockout native bindings') * * Used by the jsxToNode function. */ -export default class JsxProvider extends Provider { +export default class NativeProvider extends Provider { get FOR_NODE_TYPES () { return [ 1 ] } // document.ELEMENT_NODE nodeHasBindings (node) { - return Object.keys(node[NATIVE_BINDINGS]) + return Object.keys(node[NATIVE_BINDINGS] || {}) .some(key => key.startsWith('ko-')) } @@ -25,7 +25,7 @@ export default class JsxProvider extends Provider { */ getBindingAccessors (node) { return Object.assign({}, - ...Object.entries(node[NATIVE_BINDINGS]) + ...Object.entries(node[NATIVE_BINDINGS] || {}) .filter(([name, value]) => name.startsWith('ko-')) .map(([name, value]) => ({[name.replace(/^ko-/, '')]: () => value})) ) From 8f455b3ce35aa29366e551ae12b6d969919ff8ff Mon Sep 17 00:00:00 2001 From: Brian M Hunt Date: Wed, 27 Jun 2018 22:05:20 -0400 Subject: [PATCH 09/12] component/jsx) re-apply bindings to top-level templates --- .../spec/componentBindingBehaviors.js | 15 +++++++++++ .../src/componentBinding.js | 11 +++++--- packages/tko.utils.jsx/src/jsx.js | 25 +++++++++++++------ 3 files changed, 39 insertions(+), 12 deletions(-) diff --git a/packages/tko.binding.component/spec/componentBindingBehaviors.js b/packages/tko.binding.component/spec/componentBindingBehaviors.js index 56582a129..a73e0b03a 100644 --- a/packages/tko.binding.component/spec/componentBindingBehaviors.js +++ b/packages/tko.binding.component/spec/componentBindingBehaviors.js @@ -950,6 +950,21 @@ describe('Components: Component binding', function () { 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 diff --git a/packages/tko.binding.component/src/componentBinding.js b/packages/tko.binding.component/src/componentBinding.js index ecdbaccf5..b50713c2b 100644 --- a/packages/tko.binding.component/src/componentBinding.js +++ b/packages/tko.binding.component/src/componentBinding.js @@ -11,7 +11,7 @@ import { } from 'tko.observable' import { - DescendantBindingHandler + DescendantBindingHandler, applyBindingsToDescendants } from 'tko.bind' import { @@ -50,7 +50,10 @@ export default class ComponentBinding extends DescendantBindingHandler { if (maybeJsx(template)) { if (isObservable(template)) { - this.subscribe(template, jsx => this.setDomNodesFromJsx(jsx, element)) + this.subscribe(template, jsx => { + this.setDomNodesFromJsx(jsx, element) + applyBindingsToDescendants(this.childBindingContext, this.$element) + }) } this.setDomNodesFromJsx(unwrap(template), element) } else { @@ -129,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.utils.jsx/src/jsx.js b/packages/tko.utils.jsx/src/jsx.js index 4e281e35e..51f7b8dd4 100644 --- a/packages/tko.utils.jsx/src/jsx.js +++ b/packages/tko.utils.jsx/src/jsx.js @@ -25,15 +25,20 @@ import { * [{elementName}] * observable({elementName} | []) * - * Any observable will return truthy if its value is an array, - * since it may in future be JSX. + * 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 (value.length && value[0].elementName) { return true } - return false + if (!Array.isArray(value) || !value.length) { return false } + if (value[0] instanceof window.Node) { return false } + return true } /** @@ -45,6 +50,10 @@ export function maybeJsx (possibleJsx) { * } */ export function jsxToNode (jsx) { + if (typeof jsx === 'string') { + return document.createTextNode(jsx) + } + const node = document.createElement(jsx.elementName) const subscriptions = [] @@ -226,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]') } From 966d8bd9e9f7dba3498b768075ba8d7e376035de Mon Sep 17 00:00:00 2001 From: Brian M Hunt Date: Thu, 28 Jun 2018 09:14:45 -0400 Subject: [PATCH 10/12] tko.bind) Fix regression with `onValueUpdate` not working @ctcarton --- package.json | 1 + packages/tko.bind/src/LegacyBindingHandler.js | 21 +++++++++++-------- packages/tko.bind/src/applyBindings.js | 6 ++++++ .../tko.binding.template/src/templating.js | 2 -- 4 files changed, 19 insertions(+), 11 deletions(-) diff --git a/package.json b/package.json index 1d7ad7cae..5c8e122e9 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 fd3164478..3c94dab8c 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 af629a1aa..012c81e6b 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.template/src/templating.js b/packages/tko.binding.template/src/templating.js index f10c783da..139fec2ed 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 () { From f7ec835cce76e7489d45500474ee6e11d60db396 Mon Sep 17 00:00:00 2001 From: Brian M Hunt Date: Thu, 28 Jun 2018 10:34:52 -0400 Subject: [PATCH 11/12] tko.observable/computed) Have TC39 subscriptions fire immediately for observable/computed --- packages/tko.computed/src/computed.js | 7 ++++- packages/tko.observable/src/index.js | 2 +- packages/tko.observable/src/observable.js | 30 ++++++-------------- packages/tko.observable/src/subscribable.js | 31 ++++++++++++++------- 4 files changed, 37 insertions(+), 33 deletions(-) diff --git a/packages/tko.computed/src/computed.js b/packages/tko.computed/src/computed.js index 9503a9960..b1f4cd5c8 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/src/index.js b/packages/tko.observable/src/index.js index 8f83367fe..ac03cea89 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 f8c681119..474134d2a 100644 --- a/packages/tko.observable/src/observable.js +++ b/packages/tko.observable/src/observable.js @@ -8,32 +8,30 @@ import { 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 = Symbol('_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,23 +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') - }, - - // Note that TC39 `subscribe` ought to immediately emit. - // https://github.com/tc39/proposal-observable/issues/190 - subscribe (callback, callbackTarget, event) { - const isTC39Callback = typeof callback === 'object' && callback.next - if (isTC39Callback) { - callback.next(this()) - } - return subscribable.fn.subscribe.call(this, callback, callbackTarget, event) + 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 3507ff297..999179866 100644 --- a/packages/tko.observable/src/subscribable.js +++ b/packages/tko.observable/src/subscribable.js @@ -9,6 +9,11 @@ import { applyExtenders } from './extenders.js' import * as dependencyDetection from './dependencyDetection.js' export { isSubscribable } from './subscribableSymbol' +// 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) ko_subscribable_fn.init(this) @@ -26,7 +31,6 @@ var ko_subscribable_fn = { }, subscribe (callback, callbackTarget, event) { - const self = this // TC39 proposed standard Observable { next: () => ... } const isTC39Callback = typeof callback === 'object' && callback.next @@ -35,21 +39,28 @@ var ko_subscribable_fn = { next: callbackTarget ? callback.bind(callbackTarget) : callback } - const subscriptionInstance = new Subscription(self, observer, 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 (!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 (!self._subscriptions[event]) { - self._subscriptions[event] = [] + if (isTC39Callback && LATEST_VALUE in this) { + observer.next(this[LATEST_VALUE]) } - self._subscriptions[event].push(subscriptionInstance) return subscriptionInstance }, From 3444736344292d6bcb399b135293b15fa1352224 Mon Sep 17 00:00:00 2001 From: Brian M Hunt Date: Thu, 28 Jun 2018 10:38:46 -0400 Subject: [PATCH 12/12] jsx) Fix insert of array appending instead of inserting --- packages/tko.utils.jsx/src/jsx.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tko.utils.jsx/src/jsx.js b/packages/tko.utils.jsx/src/jsx.js index 51f7b8dd4..d136ceacd 100644 --- a/packages/tko.utils.jsx/src/jsx.js +++ b/packages/tko.utils.jsx/src/jsx.js @@ -109,7 +109,7 @@ function insertChildOrChildren (possibleTemplateElement, toAppend, beforeNode) { if (Array.isArray(toAppend)) { for (const node of toAppend) { - appendChildOrChildren(possibleTemplateElement, node) + insertChildOrChildren(possibleTemplateElement, node, beforeNode) } } else { getInsertTarget(possibleTemplateElement).insertBefore(toAppend, beforeNode)