Skip to content

Commit

Permalink
22 tc39 observables (#76)
Browse files Browse the repository at this point in the history
* 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.

tc39/proposal-observable#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 <div ko-text={this.someValue}></div>
}
```

* jsx) Add support for bindings directly referenced in JSX

Sample:

```html
 <component x={alpha}></component>
```

… 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
  • Loading branch information
brianmhunt authored Jun 28, 2018
1 parent 5615bf6 commit 178e801
Show file tree
Hide file tree
Showing 20 changed files with 403 additions and 88 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<ko binding='...'>`
* Add basic support for `ko.subscribable` as TC39-Observables

## 🚚 Alpha-4a (8 Nov 2017)

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
21 changes: 12 additions & 9 deletions packages/tko.bind/src/LegacyBindingHandler.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,23 +26,26 @@ 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)
}

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)
}
}

Expand Down
6 changes: 6 additions & 0 deletions packages/tko.bind/src/applyBindings.js
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
39 changes: 39 additions & 0 deletions packages/tko.binding.component/spec/componentBindingBehaviors.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -945,6 +949,41 @@ describe('Components: Component binding', function () {
children.unshift('rrr')
expect(testNode.children[0].innerHTML).toEqual('<b>xrrrabc</b>')
})

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 () {
Expand Down
37 changes: 26 additions & 11 deletions packages/tko.binding.component/src/componentBinding.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 0 additions & 2 deletions packages/tko.binding.template/src/templating.js
Original file line number Diff line number Diff line change
Expand Up @@ -281,8 +281,6 @@ export class TemplateBindingHandler extends AsyncBindingHandler {
} else {
this.bindAnonymousTemplate()
}

this.computed(this.onValueChange.bind(this))
}

bindNamedTemplate () {
Expand Down
7 changes: 6 additions & 1 deletion packages/tko.computed/src/computed.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ import {
extenders,
valuesArePrimitiveAndEqual,
observable,
subscribable
subscribable,
LATEST_VALUE
} from 'tko.observable'

const computedState = createSymbolOrString('_state')
Expand Down Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions packages/tko.observable/spec/observableBehaviors.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
8 changes: 8 additions & 0 deletions packages/tko.observable/spec/subscribableBehaviors.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
32 changes: 32 additions & 0 deletions packages/tko.observable/src/Subscription.js
Original file line number Diff line number Diff line change
@@ -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 }
}
2 changes: 1 addition & 1 deletion packages/tko.observable/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
22 changes: 10 additions & 12 deletions packages/tko.observable/src/observable.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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.
Expand Down
Loading

0 comments on commit 178e801

Please sign in to comment.