From 993ea2d30c5233a9abe3829d987cc79f537cc1e1 Mon Sep 17 00:00:00 2001 From: Brian M Hunt Date: Wed, 15 Apr 2026 13:16:52 -0400 Subject: [PATCH 1/5] Add Biome migration plan and initial config Migrated from ESLint + Prettier using `biome migrate` commands. Rules turned off to match current ESLint config. Co-Authored-By: Claude Opus 4.6 (1M context) --- biome.json | 65 ++++++++++++++++++++++++++++++++++++++++++++++ plans/biome.md | 70 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+) create mode 100644 biome.json create mode 100644 plans/biome.md diff --git a/biome.json b/biome.json new file mode 100644 index 00000000..adb84f68 --- /dev/null +++ b/biome.json @@ -0,0 +1,65 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.4.12/schema.json", + "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true }, + "files": { + "ignoreUnknown": true, + "includes": ["packages/**", "builds/**", "global.d.ts"] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 120, + "lineEnding": "lf" + }, + "linter": { + "enabled": true, + "includes": ["**/*.ts"], + "rules": { + "recommended": true, + "complexity": { + "noUselessEscapeInRegex": "off" + }, + "correctness": { + "noUnusedVariables": "off", + "noUnusedImports": "off" + }, + "style": { + "useConst": "off", + "useArrayLiterals": "off", + "noNamespace": "off" + }, + "suspicious": { + "noExplicitAny": "off", + "noAssignInExpressions": "off", + "noMisleadingCharacterClass": "off", + "noDoubleEquals": "off", + "noRedeclare": "off", + "noShadowRestrictedNames": "off", + "noControlCharactersInRegex": "off", + "noEmptyBlockStatements": "off", + "noPrototypeBuiltins": "off", + "noFallthroughSwitchClause": "off" + }, + "performance": { + "noDelete": "off" + } + } + }, + "javascript": { + "formatter": { + "semicolons": "asNeeded", + "trailingCommas": "none", + "quoteStyle": "single", + "arrowParentheses": "asNeeded" + } + }, + "assist": { + "enabled": true, + "actions": { + "source": { + "organizeImports": "on" + } + } + } +} diff --git a/plans/biome.md b/plans/biome.md new file mode 100644 index 00000000..3f5142a3 --- /dev/null +++ b/plans/biome.md @@ -0,0 +1,70 @@ +# Plan: Replace ESLint + Prettier with Biome + +## Context + +TKO uses ESLint (with typescript-eslint) and Prettier for linting and formatting. +Biome is a single Rust-native tool that replaces both — 27x faster on this codebase +(86ms vs 2.4s). 83% of ESLint rules migrate directly. + +## Current state + +- ESLint: 91 rules configured, most turned off. 23 errors on clean tree. +- Prettier: semi-false, single quotes, 120 width, trailing commas none. +- Both pass in CI via `lint-and-typecheck.yml`. + +## What gets deleted + +- `eslint.config.js` +- `.prettierrc` +- `.prettierignore` +- `eslint`, `@eslint/js`, `typescript-eslint`, `prettier` from devDependencies + +## What gets created + +- `biome.json` — migrated config (formatter + linter) +- `@biomejs/biome` as devDependency + +## Execution + +### Commit 1: Add Biome, auto-fix safe rules + +1. Write `biome.json` with migrated config +2. Run `biome format --write` — align formatting (matches Prettier settings) +3. Run `biome check --fix` — apply safe auto-fixes: + - `useArrowFunction` (~2,599) — `function()` → `() =>` where no `this`/`arguments` + - `useTemplate` (~123) — string concat → template literals + - `useLiteralKeys` (~68) — `obj["key"]` → `obj.key` + - `useOptionalChain` (~44) — `a && a.b` → `a?.b` + - `useImportType` (~9) — type-only imports +4. Turn off unfixable rules to silence noise: + - `noArguments` (37) — legacy `arguments` usage + - `noImplicitAnyLet` (141) — needs type annotations + - `noUnusedFunctionParameters` (134) — needs `_` prefixes + - `noGlobalEval` (12) — parser uses eval intentionally + - `noNonNullAssertion` (59) — needs null checks + - `noThisInStatic` (12) — deliberate pattern +5. Run `bun run build && bunx vitest run` — verify nothing broke +6. Risk: `useArrowFunction` in KO callbacks where `this` is externally bound. + Biome skips when `this` is lexically referenced, but can't detect external binding. + Tests catch this. + +### Commit 2: Remove ESLint + Prettier + +1. Delete `eslint.config.js`, `.prettierrc`, `.prettierignore` +2. Remove devDependencies: `eslint`, `@eslint/js`, `typescript-eslint`, `prettier` +3. Add devDependency: `@biomejs/biome` +4. Update `package.json` scripts: + - `"format"` → `"bunx biome format ."` + - `"format:fix"` → `"bunx biome format --write ."` + - `"lint"` → `"bunx biome lint ."` + - `"lint:fix"` → `"bunx biome check --fix ."` +5. Update `.github/workflows/lint-and-typecheck.yml`: + - Replace Prettier + ESLint steps with single `bunx biome ci .` +6. Update `AGENTS.md` lint/format commands + +## Verification + +1. `bunx biome ci .` — 0 errors +2. `bun run build` — all packages build +3. `bunx vitest run` — 2679 tests pass +4. `bunx tsc` — 0 type errors From 65a063defc5229e98784367be6155a7e3634cb3c Mon Sep 17 00:00:00 2001 From: Brian M Hunt Date: Wed, 15 Apr 2026 13:57:53 -0400 Subject: [PATCH 2/5] Apply Biome formatting and safe lint fixes Format all files with Biome (matches Prettier settings). Safe auto-fixes applied by `biome check --fix`: unused imports removed, unused variables prefixed, getter returns made explicit, switch fallthroughs annotated. Skipped unsafe fixes (useLiteralKeys, useOptionalChain, useTemplate) as they surface latent type errors when converting bracket to dot notation. Disabled organizeImports (reorders break TS inference in some files). All 2679 tests pass, tsc 0 errors. Co-Authored-By: Claude Opus 4.6 (1M context) --- builds/knockout/helpers/mocha-test-helpers.js | 27 +- .../spec/arrayEditDetectionBehaviors.js | 196 +- .../spec/arrayToDomEditDetectionBehaviors.js | 406 +-- builds/knockout/spec/asyncBehaviors.js | 2845 +++++++++-------- builds/knockout/spec/asyncBindingBehaviors.js | 516 +-- .../spec/bindingAttributeBehaviors.js | 1551 ++++----- .../spec/bindingDependencyBehaviors.js | 1228 +++---- .../knockout/spec/bindingGlobalsBehaviors.js | 36 +- .../spec/bindingPreprocessingBehaviors.js | 136 +- .../components/componentBindingBehaviors.js | 2093 ++++++------ .../spec/components/customElementBehaviors.js | 1151 +++---- .../spec/components/defaultLoaderBehaviors.js | 979 +++--- .../components/loaderRegistryBehaviors.js | 822 ++--- builds/knockout/spec/crossWindowBehaviors.js | 118 +- .../spec/defaultBindings/attrBehaviors.js | 177 +- .../spec/defaultBindings/checkedBehaviors.js | 1000 +++--- .../spec/defaultBindings/clickBehaviors.js | 36 +- .../spec/defaultBindings/cssBehaviors.js | 259 +- .../defaultBindings/enableDisableBehaviors.js | 62 +- .../spec/defaultBindings/eventBehaviors.js | 177 +- .../spec/defaultBindings/foreachBehaviors.js | 1787 ++++++----- .../spec/defaultBindings/hasfocusBehaviors.js | 205 +- .../spec/defaultBindings/htmlBehaviors.js | 105 +- .../spec/defaultBindings/ifBehaviors.js | 440 +-- .../spec/defaultBindings/ifnotBehaviors.js | 150 +- .../spec/defaultBindings/letBehaviors.js | 60 +- .../spec/defaultBindings/optionsBehaviors.js | 641 ++-- .../selectedOptionsBehaviors.js | 276 +- .../spec/defaultBindings/styleBehaviors.js | 116 +- .../spec/defaultBindings/submitBehaviors.js | 32 +- .../spec/defaultBindings/textBehaviors.js | 149 +- .../defaultBindings/textInputBehaviors.js | 670 ++-- .../defaultBindings/uniqueNameBehaviors.js | 20 +- .../spec/defaultBindings/usingBehaviors.js | 408 +-- .../spec/defaultBindings/valueBehaviors.js | 1456 ++++----- .../defaultBindings/visibleHiddenBehaviors.js | 74 +- .../spec/defaultBindings/withBehaviors.js | 861 ++--- .../spec/dependentObservableBehaviors.js | 1656 +++++----- .../spec/dependentObservableDomBehaviors.js | 125 +- .../knockout/spec/domNodeDisposalBehaviors.js | 412 +-- .../spec/expressionRewritingBehaviors.js | 455 +-- builds/knockout/spec/extenderBehaviors.js | 39 +- .../knockout/spec/mappingHelperBehaviors.js | 331 +- builds/knockout/spec/memoizationBehaviors.js | 117 +- .../spec/nativeTemplateEngineBehaviors.js | 387 +-- .../spec/nodePreprocessingBehaviors.js | 260 +- .../knockout/spec/observableArrayBehaviors.js | 816 ++--- .../observableArrayChangeTrackingBehaviors.js | 970 +++--- builds/knockout/spec/observableBehaviors.js | 706 ++-- .../knockout/spec/observableUtilsBehaviors.js | 216 +- builds/knockout/spec/onErrorBehaviors.js | 291 +- builds/knockout/spec/parseHtmlFragment.js | 183 +- builds/knockout/spec/pureComputedBehaviors.js | 1072 ++++--- builds/knockout/spec/subscribableBehaviors.js | 340 +- builds/knockout/spec/taskBehaviors.js | 759 +++-- builds/knockout/spec/templatingBehaviors.js | 2660 ++++++++------- builds/knockout/spec/utilsBehaviors.js | 598 ++-- builds/knockout/spec/utilsDomBehaviors.js | 185 +- builds/knockout/src/index.ts | 17 +- builds/reference/index.ts | 2 +- .../reference/spec/bindingGlobalsBehavior.js | 2 +- builds/reference/spec/iifeBehavior.js | 2 +- builds/reference/src/index.ts | 14 +- .../bind/spec/bindingAttributeBehaviors.ts | 26 +- .../spec/bindingCompletionPromiseBehavior.ts | 2 +- packages/bind/src/applyBindings.ts | 32 +- packages/bind/src/bindingContext.ts | 2 +- packages/bind/verified-behaviors.json | 29 +- .../spec/componentBindingBehaviors.ts | 22 +- packages/binding.component/src/slotBinding.ts | 4 +- .../binding.component/verified-behaviors.json | 24 +- packages/binding.core/spec/attrBehaviors.ts | 2 +- .../binding.core/spec/checkedBehaviors.ts | 76 +- .../spec/descendantsCompleteBehaviors.ts | 2 +- .../binding.core/spec/textInputBehaviors.ts | 6 +- packages/binding.core/spec/usingBehaviors.ts | 39 +- packages/binding.core/spec/valueBehaviors.ts | 16 +- packages/binding.core/src/options.ts | 16 +- packages/binding.core/src/value.ts | 10 +- packages/binding.core/verified-behaviors.json | 16 +- packages/binding.foreach/spec/eachBehavior.ts | 14 +- packages/binding.foreach/src/foreach.ts | 10 +- .../binding.foreach/verified-behaviors.json | 24 +- packages/binding.if/spec/elseBehaviors.ts | 10 +- packages/binding.if/spec/withBehaviors.ts | 34 +- packages/binding.if/verified-behaviors.json | 32 +- .../binding.template/spec/foreachBehaviors.ts | 88 +- .../spec/nativeTemplateEngineBehaviors.ts | 44 +- .../spec/templatingBehaviors.ts | 18 +- .../src/nativeTemplateEngine.ts | 2 +- .../binding.template/src/templateSources.ts | 10 +- packages/binding.template/src/templating.ts | 4 +- .../binding.template/verified-behaviors.json | 24 +- packages/builder/verified-behaviors.json | 8 +- packages/computed/src/computed.ts | 44 +- packages/computed/src/throttleExtender.ts | 2 +- packages/computed/verified-behaviors.json | 25 +- .../filter.punches/verified-behaviors.json | 20 +- packages/lifecycle/spec/LifeCycleBehaviors.ts | 1 - packages/lifecycle/src/LifeCycle.ts | 4 +- packages/lifecycle/verified-behaviors.json | 20 +- .../observable/spec/mappingHelperBehaviors.ts | 2 +- packages/observable/src/mappingHelpers.ts | 16 +- packages/observable/src/observable.ts | 3 +- .../src/observableArray.changeTracking.ts | 2 + packages/observable/verified-behaviors.json | 24 +- .../provider.attr/verified-behaviors.json | 12 +- .../verified-behaviors.json | 12 +- .../spec/customElementBehaviors.ts | 36 +- .../verified-behaviors.json | 16 +- .../spec/dataBindProviderBehaviors.ts | 2 +- .../provider.databind/verified-behaviors.json | 24 +- .../provider.multi/verified-behaviors.json | 20 +- .../spec/attributeInterpolationSpec.ts | 4 +- .../provider.mustache/verified-behaviors.json | 16 +- .../provider.native/verified-behaviors.json | 16 +- .../provider.virtual/verified-behaviors.json | 20 +- packages/provider/spec/providerBehaviors.ts | 2 +- packages/provider/verified-behaviors.json | 12 +- packages/utils.component/src/ComponentABC.ts | 3 +- .../utils.component/verified-behaviors.json | 20 +- .../verified-behaviors.json | 16 +- packages/utils.jsx/verified-behaviors.json | 24 +- packages/utils.parser/spec/parserBehaviors.ts | 26 +- .../utils.parser/spec/preparserBehavior.ts | 34 +- packages/utils.parser/src/Identifier.ts | 12 +- packages/utils.parser/src/Node.ts | 18 +- packages/utils.parser/src/Parser.ts | 36 +- packages/utils.parser/verified-behaviors.json | 20 +- packages/utils/spec/memoizationBehaviors.ts | 4 +- packages/utils/src/dom/fixes.ts | 4 +- packages/utils/src/dom/virtualElements.ts | 8 +- packages/utils/src/options.ts | 2 +- packages/utils/src/tasks.ts | 8 +- packages/utils/verified-behaviors.json | 28 +- 135 files changed, 18298 insertions(+), 16846 deletions(-) diff --git a/builds/knockout/helpers/mocha-test-helpers.js b/builds/knockout/helpers/mocha-test-helpers.js index f4c60e4d..bb7dd38e 100644 --- a/builds/knockout/helpers/mocha-test-helpers.js +++ b/builds/knockout/helpers/mocha-test-helpers.js @@ -49,12 +49,14 @@ function detectIEVersion() { const div = document.createElement('div') const iElems = div.getElementsByTagName('i') - while ((div.innerHTML = ''), iElems[0]) {} + while (((div.innerHTML = ''), iElems[0])) {} return version > 4 ? version : undefined } function expectEqualOneOf(actual, expectedPossibilities) { - const matches = expectedPossibilities.some(function (expected) { return chai.util.eql(actual, expected) }) + const matches = expectedPossibilities.some(function (expected) { + return chai.util.eql(actual, expected) + }) expect(matches, 'expected value to deeply equal one of the provided possibilities').to.equal(true) } @@ -93,18 +95,31 @@ function expectHaveTexts(actual, expectedTexts) { } function expectHaveValues(actual, expectedValues) { - const values = ko.utils.arrayFilter(ko.utils.arrayMap(actual.childNodes, function (node) { return node.value }), function (value) { return value !== undefined }) + const values = ko.utils.arrayFilter( + ko.utils.arrayMap(actual.childNodes, function (node) { + return node.value + }), + function (value) { + return value !== undefined + } + ) expect(values).to.deep.equal(expectedValues) } function expectHaveCheckedStates(actual, expectedValues) { - const values = ko.utils.arrayMap(actual.childNodes, function (node) { return node.checked }) + const values = ko.utils.arrayMap(actual.childNodes, function (node) { + return node.checked + }) expect(values).to.deep.equal(expectedValues) } function expectHaveSelectedValues(actual, expectedValues) { - const selectedNodes = ko.utils.arrayFilter(actual.childNodes, function (node) { return node.selected }) - const selectedValues = ko.utils.arrayMap(selectedNodes, function (node) { return ko.selectExtensions.readValue(node) }) + const selectedNodes = ko.utils.arrayFilter(actual.childNodes, function (node) { + return node.selected + }) + const selectedValues = ko.utils.arrayMap(selectedNodes, function (node) { + return ko.selectExtensions.readValue(node) + }) expect(selectedValues).to.deep.equal(expectedValues) } diff --git a/builds/knockout/spec/arrayEditDetectionBehaviors.js b/builds/knockout/spec/arrayEditDetectionBehaviors.js index 067b8433..5f91037a 100644 --- a/builds/knockout/spec/arrayEditDetectionBehaviors.js +++ b/builds/knockout/spec/arrayEditDetectionBehaviors.js @@ -1,105 +1,111 @@ +describe('Compare Arrays', function () { + it('Should recognize when two arrays have the same contents', function () { + var subject = ['A', {}, function () {}] + var compareResult = ko.utils.compareArrays(subject, subject.slice(0)) -describe('Compare Arrays', function() { - it('Should recognize when two arrays have the same contents', function () { - var subject = ["A", {}, function () { } ]; - var compareResult = ko.utils.compareArrays(subject, subject.slice(0)); + expect(compareResult.length).to.deep.equal(subject.length) + for (var i = 0; i < subject.length; i++) { + expect(compareResult[i].status).to.deep.equal('retained') + expect(compareResult[i].value).to.deep.equal(subject[i]) + } + }) - expect(compareResult.length).to.deep.equal(subject.length); - for (var i = 0; i < subject.length; i++) { - expect(compareResult[i].status).to.deep.equal("retained"); - expect(compareResult[i].value).to.deep.equal(subject[i]); - } - }); + it('Should recognize added items', function () { + var oldArray = ['A', 'B'] + var newArray = ['A', 'A2', 'A3', 'B', 'B2'] + var compareResult = ko.utils.compareArrays(oldArray, newArray) + expect(compareResult).to.deep.equal([ + { status: 'retained', value: 'A' }, + { status: 'added', value: 'A2', index: 1 }, + { status: 'added', value: 'A3', index: 2 }, + { status: 'retained', value: 'B' }, + { status: 'added', value: 'B2', index: 4 } + ]) + }) - it('Should recognize added items', function () { - var oldArray = ["A", "B"]; - var newArray = ["A", "A2", "A3", "B", "B2"]; - var compareResult = ko.utils.compareArrays(oldArray, newArray); - expect(compareResult).to.deep.equal([ - { status: "retained", value: "A" }, - { status: "added", value: "A2", index: 1 }, - { status: "added", value: "A3", index: 2 }, - { status: "retained", value: "B" }, - { status: "added", value: "B2", index: 4 } - ]); - }); + it('Should recognize deleted items', function () { + var oldArray = ['A', 'B', 'C', 'D', 'E'] + var newArray = ['B', 'C', 'E'] + var compareResult = ko.utils.compareArrays(oldArray, newArray) + expect(compareResult).to.deep.equal([ + { status: 'deleted', value: 'A', index: 0 }, + { status: 'retained', value: 'B' }, + { status: 'retained', value: 'C' }, + { status: 'deleted', value: 'D', index: 3 }, + { status: 'retained', value: 'E' } + ]) + }) - it('Should recognize deleted items', function () { - var oldArray = ["A", "B", "C", "D", "E"]; - var newArray = ["B", "C", "E"]; - var compareResult = ko.utils.compareArrays(oldArray, newArray); - expect(compareResult).to.deep.equal([ - { status: "deleted", value: "A", index: 0 }, - { status: "retained", value: "B" }, - { status: "retained", value: "C" }, - { status: "deleted", value: "D", index: 3 }, - { status: "retained", value: "E" } - ]); - }); + it('Should recognize mixed edits', function () { + var oldArray = ['A', 'B', 'C', 'D', 'E'] + var newArray = [123, 'A', 'E', 'C', 'D'] + var compareResult = ko.utils.compareArrays(oldArray, newArray) + expect(compareResult).to.deep.equal([ + { status: 'added', value: 123, index: 0 }, + { status: 'retained', value: 'A' }, + { status: 'deleted', value: 'B', index: 1 }, + { status: 'added', value: 'E', index: 2, moved: 4 }, + { status: 'retained', value: 'C' }, + { status: 'retained', value: 'D' }, + { status: 'deleted', value: 'E', index: 4, moved: 2 } + ]) + }) - it('Should recognize mixed edits', function () { - var oldArray = ["A", "B", "C", "D", "E"]; - var newArray = [123, "A", "E", "C", "D"]; - var compareResult = ko.utils.compareArrays(oldArray, newArray); - expect(compareResult).to.deep.equal([ - { status: "added", value: 123, index: 0 }, - { status: "retained", value: "A" }, - { status: "deleted", value: "B", index: 1 }, - { status: "added", value: "E", index: 2, moved: 4 }, - { status: "retained", value: "C" }, - { status: "retained", value: "D" }, - { status: "deleted", value: "E", index: 4, moved: 2 } - ]); - }); + it('Should recognize replaced array', function () { + var oldArray = ['A', 'B', 'C', 'D', 'E'] + var newArray = ['F', 'G', 'H', 'I', 'J'] + var compareResult = ko.utils.compareArrays(oldArray, newArray) + // The ordering of added/deleted items for replaced entries isn't defined, so + // we'll sort the results first to ensure the results are in a known order for verification. + compareResult.sort(function (a, b) { + return a.index - b.index || a.status.localeCompare(b.status) + }) + expect(compareResult).to.deep.equal([ + { status: 'added', value: 'F', index: 0 }, + { status: 'deleted', value: 'A', index: 0 }, + { status: 'added', value: 'G', index: 1 }, + { status: 'deleted', value: 'B', index: 1 }, + { status: 'added', value: 'H', index: 2 }, + { status: 'deleted', value: 'C', index: 2 }, + { status: 'added', value: 'I', index: 3 }, + { status: 'deleted', value: 'D', index: 3 }, + { status: 'added', value: 'J', index: 4 }, + { status: 'deleted', value: 'E', index: 4 } + ]) + }) - it('Should recognize replaced array', function () { - var oldArray = ["A", "B", "C", "D", "E"]; - var newArray = ["F", "G", "H", "I", "J"]; - var compareResult = ko.utils.compareArrays(oldArray, newArray); - // The ordering of added/deleted items for replaced entries isn't defined, so - // we'll sort the results first to ensure the results are in a known order for verification. - compareResult.sort(function(a, b) { return (a.index - b.index) || a.status.localeCompare(b.status); }); - expect(compareResult).to.deep.equal([ - { status : 'added', value : 'F', index : 0 }, - { status : 'deleted', value : 'A', index : 0 }, - { status : 'added', value : 'G', index : 1 }, - { status : 'deleted', value : 'B', index : 1 }, - { status : 'added', value : 'H', index : 2 }, - { status : 'deleted', value : 'C', index : 2 }, - { status : 'added', value : 'I', index : 3 }, - { status : 'deleted', value : 'D', index : 3 }, - { status : 'added', value : 'J', index : 4 }, - { status : 'deleted', value : 'E', index : 4 } - ]); - }); + it('Should support sparse diffs', function () { + // A sparse diff is exactly like a regular diff, except it doesn't contain any + // 'retained' items. This still preserves enough information for most things + // you'd want to do with the changeset. - it('Should support sparse diffs', function() { - // A sparse diff is exactly like a regular diff, except it doesn't contain any - // 'retained' items. This still preserves enough information for most things - // you'd want to do with the changeset. + var oldArray = ['A', 'B', 'C', 'D', 'E'] + var newArray = [123, 'A', 'E', 'C', 'D'] + var compareResult = ko.utils.compareArrays(oldArray, newArray, { sparse: true }) + expect(compareResult).to.deep.equal([ + { status: 'added', value: 123, index: 0 }, + { status: 'deleted', value: 'B', index: 1 }, + { status: 'added', value: 'E', index: 2, moved: 4 }, + { status: 'deleted', value: 'E', index: 4, moved: 2 } + ]) + }) - var oldArray = ["A", "B", "C", "D", "E"]; - var newArray = [123, "A", "E", "C", "D"]; - var compareResult = ko.utils.compareArrays(oldArray, newArray, { sparse: true }); - expect(compareResult).to.deep.equal([ - { status: "added", value: 123, index: 0 }, - { status: "deleted", value: "B", index: 1 }, - { status: "added", value: "E", index: 2, moved: 4 }, - { status: "deleted", value: "E", index: 4, moved: 2 } - ]); - }); + it('Should honor "dontLimitMoves" option', function () { + // In order to test this, we must have a scenario in which a move is not recognized as such without the option. + // This scenario doesn't represent the definition of the spec itself and may need to be modified if the move + // detection algorithm in Knockout is changed. + var oldArray = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T'] + var newArray = [1, 2, 3, 4, 'T', 6, 7, 8, 9, 10] - it('Should honor "dontLimitMoves" option', function() { - // In order to test this, we must have a scenario in which a move is not recognized as such without the option. - // This scenario doesn't represent the definition of the spec itself and may need to be modified if the move - // detection algorithm in Knockout is changed. - var oldArray = ["A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T"]; - var newArray = [1, 2, 3, 4, "T", 6, 7, 8, 9, 10]; + var compareResult = ko.utils.compareArrays(oldArray, newArray) + expect(compareResult[compareResult.length - 1]).to.deep.equal({ status: 'deleted', value: 'T', index: 19 }) - var compareResult = ko.utils.compareArrays(oldArray, newArray); - expect(compareResult[compareResult.length-1]).to.deep.equal({ status: 'deleted', value: 'T', index: 19 }); - - compareResult = ko.utils.compareArrays(oldArray, newArray, { dontLimitMoves: true }); - expect(compareResult[compareResult.length-1]).to.deep.equal({ status: 'deleted', value: 'T', index: 19, moved: 4 }); - }); -}); + compareResult = ko.utils.compareArrays(oldArray, newArray, { dontLimitMoves: true }) + expect(compareResult[compareResult.length - 1]).to.deep.equal({ + status: 'deleted', + value: 'T', + index: 19, + moved: 4 + }) + }) +}) diff --git a/builds/knockout/spec/arrayToDomEditDetectionBehaviors.js b/builds/knockout/spec/arrayToDomEditDetectionBehaviors.js index 980d5029..b10d8762 100644 --- a/builds/knockout/spec/arrayToDomEditDetectionBehaviors.js +++ b/builds/knockout/spec/arrayToDomEditDetectionBehaviors.js @@ -1,186 +1,226 @@ - function copyDomNodeChildren(domNode) { - var copy = []; - for (var i = 0; i < domNode.childNodes.length; i++) - copy.push(domNode.childNodes[i]); - return copy; + var copy = [] + for (var i = 0; i < domNode.childNodes.length; i++) copy.push(domNode.childNodes[i]) + return copy } -describe('Array to DOM node children mapping', function() { - beforeEach(prepareTestNode); - - it('Should populate the DOM node by mapping array elements', function () { - var array = ["A", "B"]; - var mapping = function (arrayItem) { - var output1 = document.createElement("DIV"); - var output2 = document.createElement("DIV"); - output1.innerHTML = arrayItem + "1"; - output2.innerHTML = arrayItem + "2"; - return [output1, output2]; - }; - ko.utils.setDomNodeChildrenFromArrayMapping(testNode, array, mapping); - expect(testNode.childNodes.length).to.deep.equal(4); - expect(testNode.childNodes[0].innerHTML).to.deep.equal("A1"); - expect(testNode.childNodes[1].innerHTML).to.deep.equal("A2"); - expect(testNode.childNodes[2].innerHTML).to.deep.equal("B1"); - expect(testNode.childNodes[3].innerHTML).to.deep.equal("B2"); - }); - - it('Should only call the mapping function for new array elements', function () { - var mappingInvocations = []; - var mapping = function (arrayItem) { - mappingInvocations.push(arrayItem); - return null; - }; - ko.utils.setDomNodeChildrenFromArrayMapping(testNode, ["A", "B"], mapping); - expect(mappingInvocations).to.deep.equal(["A", "B"]); - - mappingInvocations = []; - ko.utils.setDomNodeChildrenFromArrayMapping(testNode, ["A", "A2", "B"], mapping); - expect(mappingInvocations).to.deep.equal(["A2"]); - }); - - it('Should retain existing node instances if the array is unchanged', function () { - var array = ["A", "B"]; - var mapping = function (arrayItem) { - var output1 = document.createElement("DIV"); - var output2 = document.createElement("DIV"); - output1.innerHTML = arrayItem + "1"; - output2.innerHTML = arrayItem + "2"; - return [output1, output2]; - }; - - ko.utils.setDomNodeChildrenFromArrayMapping(testNode, array, mapping); - var existingInstances = copyDomNodeChildren(testNode); - - ko.utils.setDomNodeChildrenFromArrayMapping(testNode, array, mapping); - var newInstances = copyDomNodeChildren(testNode); - - expect(newInstances).to.deep.equal(existingInstances); - }); - - it('Should insert added nodes at the corresponding place in the DOM', function () { - var mappingInvocations = []; - var mapping = function (arrayItem) { - mappingInvocations.push(arrayItem); - var output = document.createElement("DIV"); - output.innerHTML = arrayItem; - return [output]; - }; - - ko.utils.setDomNodeChildrenFromArrayMapping(testNode, ["A", "B"], mapping); - expect(ko.utils.arrayMap(testNode.childNodes, function (x) { return x.innerHTML })).to.deep.equal(["A", "B"]); - expect(mappingInvocations).to.deep.equal(["A", "B"]); - - mappingInvocations = []; - ko.utils.setDomNodeChildrenFromArrayMapping(testNode, ["first", "A", "middle1", "middle2", "B", "last"], mapping); - expect(ko.utils.arrayMap(testNode.childNodes, function (x) { return x.innerHTML })).to.deep.equal(["first", "A", "middle1", "middle2", "B", "last"]); - expect(mappingInvocations).to.deep.equal(["first", "middle1", "middle2", "last"]); - }); - - it('Should remove deleted nodes from the DOM', function () { - var mappingInvocations = []; - var mapping = function (arrayItem) { - mappingInvocations.push(arrayItem); - var output = document.createElement("DIV"); - output.innerHTML = arrayItem; - return [output]; - }; - - ko.utils.setDomNodeChildrenFromArrayMapping(testNode, ["first", "A", "middle1", "middle2", "B", "last"], mapping); - expect(ko.utils.arrayMap(testNode.childNodes, function (x) { return x.innerHTML })).to.deep.equal(["first", "A", "middle1", "middle2", "B", "last"]); - expect(mappingInvocations).to.deep.equal(["first", "A", "middle1", "middle2", "B", "last"]); - - mappingInvocations = []; - ko.utils.setDomNodeChildrenFromArrayMapping(testNode, ["A", "B"], mapping); - expect(ko.utils.arrayMap(testNode.childNodes, function (x) { return x.innerHTML })).to.deep.equal(["A", "B"]); - expect(mappingInvocations).to.deep.equal([]); - }); - - it('Should tolerate DOM nodes being removed manually, before the corresponding array entry is removed', function() { - // Represents https://github.com/SteveSanderson/knockout/issues/413 - // Ideally, people wouldn't be mutating the generated DOM manually. But this didn't error in v2.0, so we should try to avoid introducing a break. - var mappingInvocations = []; - var mapping = function (arrayItem) { - mappingInvocations.push(arrayItem); - var output = document.createElement("DIV"); - output.innerHTML = arrayItem; - return [output]; - }; - - ko.utils.setDomNodeChildrenFromArrayMapping(testNode, ["A", "B", "C"], mapping); - expectContainHtml(testNode, "
a
b
c
"); - - // Now kill the middle DIV manually, even though people shouldn't really do this - var elemToRemove = testNode.childNodes[1]; - expect(elemToRemove.innerHTML).to.deep.equal("B"); // Be sure it's the right one - elemToRemove.parentNode.removeChild(elemToRemove); - - // Now remove the corresponding array entry. This shouldn't cause an exception. - ko.utils.setDomNodeChildrenFromArrayMapping(testNode, ["A", "C"], mapping); - expectContainHtml(testNode, "
a
c
"); - }); - - it('Should handle sequences of mixed insertions and deletions', function () { - var mappingInvocations = [], countCallbackInvocations = 0; - var mapping = function (arrayItem) { - mappingInvocations.push(arrayItem); - var output = document.createElement("DIV"); - output.innerHTML = ko.utils.unwrapObservable(arrayItem) || "null"; - return [output]; - }; - var callback = function(arrayItem, nodes) { - ++countCallbackInvocations; - expect(mappingInvocations[mappingInvocations.length-1]).to.deep.equal(arrayItem); - } - - ko.utils.setDomNodeChildrenFromArrayMapping(testNode, ["A"], mapping, null, callback); - expect(ko.utils.arrayMap(testNode.childNodes, function (x) { return x.innerHTML })).to.deep.equal(["A"]); - expect(mappingInvocations).to.deep.equal(["A"]); - expect(countCallbackInvocations).to.deep.equal(mappingInvocations.length); - - mappingInvocations = [], countCallbackInvocations = 0; - ko.utils.setDomNodeChildrenFromArrayMapping(testNode, ["B"], mapping, null, callback); // Delete and replace single item - expect(ko.utils.arrayMap(testNode.childNodes, function (x) { return x.innerHTML })).to.deep.equal(["B"]); - expect(mappingInvocations).to.deep.equal(["B"]); - expect(countCallbackInvocations).to.deep.equal(mappingInvocations.length); - - mappingInvocations = [], countCallbackInvocations = 0; - ko.utils.setDomNodeChildrenFromArrayMapping(testNode, ["A", "B", "C"], mapping, null, callback); // Add at beginning and end - expect(ko.utils.arrayMap(testNode.childNodes, function (x) { return x.innerHTML })).to.deep.equal(["A", "B", "C"]); - expect(mappingInvocations).to.deep.equal(["A", "C"]); - expect(countCallbackInvocations).to.deep.equal(mappingInvocations.length); - - mappingInvocations = [], countCallbackInvocations = 0; - ko.utils.setDomNodeChildrenFromArrayMapping(testNode, ["C", "B", "A"], mapping, null, callback); // Move items - expect(ko.utils.arrayMap(testNode.childNodes, function (x) { return x.innerHTML })).to.deep.equal(["C", "B", "A"]); - expect(mappingInvocations).to.deep.equal([]); - expect(countCallbackInvocations).to.deep.equal(mappingInvocations.length); - - // Check that observable items can be added and unwrapped in the mapping function and will update the DOM. - // Also check that observables accessed in the callback function do not update the DOM. - mappingInvocations = [], countCallbackInvocations = 0; - var observable = ko.observable(1), callbackObservable = ko.observable(1); - var callback2 = function(arrayItem, nodes) { - callbackObservable(); - callback(arrayItem, nodes); - }; - ko.utils.setDomNodeChildrenFromArrayMapping(testNode, [observable, null, "B"], mapping, null, callback2); // Add to beginning; delete from end - expect(ko.utils.arrayMap(testNode.childNodes, function (x) { return x.innerHTML })).to.deep.equal(["1", "null", "B"]); - expect(mappingInvocations).to.deep.equal([observable, null]); - expect(countCallbackInvocations).to.deep.equal(mappingInvocations.length); - - // Change the value of the mapped observable and verify that the DOM is updated - mappingInvocations = [], countCallbackInvocations = 0; - observable(2); - expect(ko.utils.arrayMap(testNode.childNodes, function (x) { return x.innerHTML })).to.deep.equal(["2", "null", "B"]); - expect(mappingInvocations).to.deep.equal([observable]); - expect(countCallbackInvocations).to.deep.equal(mappingInvocations.length); - - // Change the value of the callback observable and verify that the DOM wasn't updated - mappingInvocations = [], countCallbackInvocations = 0; - callbackObservable(2); - expect(mappingInvocations.length).to.deep.equal(0); - expect(countCallbackInvocations).to.deep.equal(0); - }); -}); +describe('Array to DOM node children mapping', function () { + beforeEach(prepareTestNode) + + it('Should populate the DOM node by mapping array elements', function () { + var array = ['A', 'B'] + var mapping = function (arrayItem) { + var output1 = document.createElement('DIV') + var output2 = document.createElement('DIV') + output1.innerHTML = arrayItem + '1' + output2.innerHTML = arrayItem + '2' + return [output1, output2] + } + ko.utils.setDomNodeChildrenFromArrayMapping(testNode, array, mapping) + expect(testNode.childNodes.length).to.deep.equal(4) + expect(testNode.childNodes[0].innerHTML).to.deep.equal('A1') + expect(testNode.childNodes[1].innerHTML).to.deep.equal('A2') + expect(testNode.childNodes[2].innerHTML).to.deep.equal('B1') + expect(testNode.childNodes[3].innerHTML).to.deep.equal('B2') + }) + + it('Should only call the mapping function for new array elements', function () { + var mappingInvocations = [] + var mapping = function (arrayItem) { + mappingInvocations.push(arrayItem) + return null + } + ko.utils.setDomNodeChildrenFromArrayMapping(testNode, ['A', 'B'], mapping) + expect(mappingInvocations).to.deep.equal(['A', 'B']) + + mappingInvocations = [] + ko.utils.setDomNodeChildrenFromArrayMapping(testNode, ['A', 'A2', 'B'], mapping) + expect(mappingInvocations).to.deep.equal(['A2']) + }) + + it('Should retain existing node instances if the array is unchanged', function () { + var array = ['A', 'B'] + var mapping = function (arrayItem) { + var output1 = document.createElement('DIV') + var output2 = document.createElement('DIV') + output1.innerHTML = arrayItem + '1' + output2.innerHTML = arrayItem + '2' + return [output1, output2] + } + + ko.utils.setDomNodeChildrenFromArrayMapping(testNode, array, mapping) + var existingInstances = copyDomNodeChildren(testNode) + + ko.utils.setDomNodeChildrenFromArrayMapping(testNode, array, mapping) + var newInstances = copyDomNodeChildren(testNode) + + expect(newInstances).to.deep.equal(existingInstances) + }) + + it('Should insert added nodes at the corresponding place in the DOM', function () { + var mappingInvocations = [] + var mapping = function (arrayItem) { + mappingInvocations.push(arrayItem) + var output = document.createElement('DIV') + output.innerHTML = arrayItem + return [output] + } + + ko.utils.setDomNodeChildrenFromArrayMapping(testNode, ['A', 'B'], mapping) + expect( + ko.utils.arrayMap(testNode.childNodes, function (x) { + return x.innerHTML + }) + ).to.deep.equal(['A', 'B']) + expect(mappingInvocations).to.deep.equal(['A', 'B']) + + mappingInvocations = [] + ko.utils.setDomNodeChildrenFromArrayMapping(testNode, ['first', 'A', 'middle1', 'middle2', 'B', 'last'], mapping) + expect( + ko.utils.arrayMap(testNode.childNodes, function (x) { + return x.innerHTML + }) + ).to.deep.equal(['first', 'A', 'middle1', 'middle2', 'B', 'last']) + expect(mappingInvocations).to.deep.equal(['first', 'middle1', 'middle2', 'last']) + }) + + it('Should remove deleted nodes from the DOM', function () { + var mappingInvocations = [] + var mapping = function (arrayItem) { + mappingInvocations.push(arrayItem) + var output = document.createElement('DIV') + output.innerHTML = arrayItem + return [output] + } + + ko.utils.setDomNodeChildrenFromArrayMapping(testNode, ['first', 'A', 'middle1', 'middle2', 'B', 'last'], mapping) + expect( + ko.utils.arrayMap(testNode.childNodes, function (x) { + return x.innerHTML + }) + ).to.deep.equal(['first', 'A', 'middle1', 'middle2', 'B', 'last']) + expect(mappingInvocations).to.deep.equal(['first', 'A', 'middle1', 'middle2', 'B', 'last']) + + mappingInvocations = [] + ko.utils.setDomNodeChildrenFromArrayMapping(testNode, ['A', 'B'], mapping) + expect( + ko.utils.arrayMap(testNode.childNodes, function (x) { + return x.innerHTML + }) + ).to.deep.equal(['A', 'B']) + expect(mappingInvocations).to.deep.equal([]) + }) + + it('Should tolerate DOM nodes being removed manually, before the corresponding array entry is removed', function () { + // Represents https://github.com/SteveSanderson/knockout/issues/413 + // Ideally, people wouldn't be mutating the generated DOM manually. But this didn't error in v2.0, so we should try to avoid introducing a break. + var mappingInvocations = [] + var mapping = function (arrayItem) { + mappingInvocations.push(arrayItem) + var output = document.createElement('DIV') + output.innerHTML = arrayItem + return [output] + } + + ko.utils.setDomNodeChildrenFromArrayMapping(testNode, ['A', 'B', 'C'], mapping) + expectContainHtml(testNode, '
a
b
c
') + + // Now kill the middle DIV manually, even though people shouldn't really do this + var elemToRemove = testNode.childNodes[1] + expect(elemToRemove.innerHTML).to.deep.equal('B') // Be sure it's the right one + elemToRemove.parentNode.removeChild(elemToRemove) + + // Now remove the corresponding array entry. This shouldn't cause an exception. + ko.utils.setDomNodeChildrenFromArrayMapping(testNode, ['A', 'C'], mapping) + expectContainHtml(testNode, '
a
c
') + }) + + it('Should handle sequences of mixed insertions and deletions', function () { + var mappingInvocations = [], + countCallbackInvocations = 0 + var mapping = function (arrayItem) { + mappingInvocations.push(arrayItem) + var output = document.createElement('DIV') + output.innerHTML = ko.utils.unwrapObservable(arrayItem) || 'null' + return [output] + } + var callback = function (arrayItem, nodes) { + ++countCallbackInvocations + expect(mappingInvocations[mappingInvocations.length - 1]).to.deep.equal(arrayItem) + } + + ko.utils.setDomNodeChildrenFromArrayMapping(testNode, ['A'], mapping, null, callback) + expect( + ko.utils.arrayMap(testNode.childNodes, function (x) { + return x.innerHTML + }) + ).to.deep.equal(['A']) + expect(mappingInvocations).to.deep.equal(['A']) + expect(countCallbackInvocations).to.deep.equal(mappingInvocations.length) + + ;(mappingInvocations = []), (countCallbackInvocations = 0) + ko.utils.setDomNodeChildrenFromArrayMapping(testNode, ['B'], mapping, null, callback) // Delete and replace single item + expect( + ko.utils.arrayMap(testNode.childNodes, function (x) { + return x.innerHTML + }) + ).to.deep.equal(['B']) + expect(mappingInvocations).to.deep.equal(['B']) + expect(countCallbackInvocations).to.deep.equal(mappingInvocations.length) + + ;(mappingInvocations = []), (countCallbackInvocations = 0) + ko.utils.setDomNodeChildrenFromArrayMapping(testNode, ['A', 'B', 'C'], mapping, null, callback) // Add at beginning and end + expect( + ko.utils.arrayMap(testNode.childNodes, function (x) { + return x.innerHTML + }) + ).to.deep.equal(['A', 'B', 'C']) + expect(mappingInvocations).to.deep.equal(['A', 'C']) + expect(countCallbackInvocations).to.deep.equal(mappingInvocations.length) + + ;(mappingInvocations = []), (countCallbackInvocations = 0) + ko.utils.setDomNodeChildrenFromArrayMapping(testNode, ['C', 'B', 'A'], mapping, null, callback) // Move items + expect( + ko.utils.arrayMap(testNode.childNodes, function (x) { + return x.innerHTML + }) + ).to.deep.equal(['C', 'B', 'A']) + expect(mappingInvocations).to.deep.equal([]) + expect(countCallbackInvocations).to.deep.equal(mappingInvocations.length) + + // Check that observable items can be added and unwrapped in the mapping function and will update the DOM. + // Also check that observables accessed in the callback function do not update the DOM. + ;(mappingInvocations = []), (countCallbackInvocations = 0) + var observable = ko.observable(1), + callbackObservable = ko.observable(1) + var callback2 = function (arrayItem, nodes) { + callbackObservable() + callback(arrayItem, nodes) + } + ko.utils.setDomNodeChildrenFromArrayMapping(testNode, [observable, null, 'B'], mapping, null, callback2) // Add to beginning; delete from end + expect( + ko.utils.arrayMap(testNode.childNodes, function (x) { + return x.innerHTML + }) + ).to.deep.equal(['1', 'null', 'B']) + expect(mappingInvocations).to.deep.equal([observable, null]) + expect(countCallbackInvocations).to.deep.equal(mappingInvocations.length) + + // Change the value of the mapped observable and verify that the DOM is updated + ;(mappingInvocations = []), (countCallbackInvocations = 0) + observable(2) + expect( + ko.utils.arrayMap(testNode.childNodes, function (x) { + return x.innerHTML + }) + ).to.deep.equal(['2', 'null', 'B']) + expect(mappingInvocations).to.deep.equal([observable]) + expect(countCallbackInvocations).to.deep.equal(mappingInvocations.length) + + // Change the value of the callback observable and verify that the DOM wasn't updated + ;(mappingInvocations = []), (countCallbackInvocations = 0) + callbackObservable(2) + expect(mappingInvocations.length).to.deep.equal(0) + expect(countCallbackInvocations).to.deep.equal(0) + }) +}) diff --git a/builds/knockout/spec/asyncBehaviors.js b/builds/knockout/spec/asyncBehaviors.js index 8616a393..350f3873 100644 --- a/builds/knockout/spec/asyncBehaviors.js +++ b/builds/knockout/spec/asyncBehaviors.js @@ -1,1336 +1,1511 @@ -var clock; - -describe("Throttled observables", function() { - beforeEach(function() { - clock = sinon.useFakeTimers(); - }); // Workaround for spurious timing-related failures on IE8 (issue #736) - - afterEach(function() { - clock.restore(); - clock = null; - }); - - it("Should notify subscribers asynchronously after writes stop for the specified timeout duration", function() { - var observable = ko.observable('A').extend({ throttle: 100 }); - var notifiedValues = []; - observable.subscribe(function(value) { - notifiedValues.push(value); - }); - - // Mutate a few times - observable('B'); - observable('C'); - observable('D'); - expect(notifiedValues.length).to.deep.equal(0); // Should not notify synchronously - - clock.tick(10); - - // Mutate more - observable('E'); - observable('F'); - expect(notifiedValues.length).to.deep.equal(0); // Should not notify until end of throttle timeout - - clock.tick(100); - expect(notifiedValues.length).to.deep.equal(1); - expect(notifiedValues[0]).to.deep.equal("F"); - }); -}); - -describe("Throttled dependent observables", function() { - beforeEach(function() { - clock = sinon.useFakeTimers(); - }); // Workaround for spurious timing-related failures on IE8 (issue #736) - - afterEach(function() { - clock.restore(); - clock = null; - }); - - it("Should notify subscribers asynchronously after dependencies stop updating for the specified timeout duration", function() { - var underlying = ko.observable(); - var asyncDepObs = ko.dependentObservable(function() { - return underlying(); - }).extend({ throttle: 100 }); - var notifiedValues = []; - asyncDepObs.subscribe(function(value) { - notifiedValues.push(value); - }); - - // Check initial state - expect(asyncDepObs()).to.equal(undefined); - // Mutate - underlying('New value'); - expect(asyncDepObs()).to.equal(undefined); // Should not update synchronously - expect(notifiedValues.length).to.deep.equal(0); - - // Still shouldn't have evaluated - clock.tick(10); - expect(asyncDepObs()).to.equal(undefined); // Should not update until throttle timeout - expect(notifiedValues.length).to.deep.equal(0); - - // Now wait for throttle timeout - clock.tick(100); - expect(asyncDepObs()).to.deep.equal('New value'); - expect(notifiedValues.length).to.deep.equal(1); - expect(notifiedValues[0]).to.deep.equal('New value'); - }); - - it("Should run evaluator only once when dependencies stop updating for the specified timeout duration", function() { - var evaluationCount = 0; - var someDependency = ko.observable(); - var asyncDepObs = ko.dependentObservable(function() { - evaluationCount++; - return someDependency(); - }).extend({ throttle: 100 }); - - // Mutate a few times synchronously - expect(evaluationCount).to.deep.equal(1); // Evaluates synchronously when first created, like all dependent observables - someDependency("A"); - someDependency("B"); - someDependency("C"); - expect(evaluationCount).to.deep.equal(1); // Should not re-evaluate synchronously when dependencies update - - // Also mutate async - clock.tick(10); - someDependency("D"); - expect(evaluationCount).to.deep.equal(1); - - // Now wait for throttle timeout - clock.tick(100); - expect(evaluationCount).to.deep.equal(2); // Finally, it's evaluated - expect(asyncDepObs()).to.deep.equal("D"); - }); -}); - -describe('Rate-limited', function() { - beforeEach(function() { - clock = sinon.useFakeTimers(); - }); - - afterEach(function() { - clock.restore(); - clock = null; - }); - - describe('Subscribable', function() { - it('Should delay change notifications', function() { - var subscribable = new ko.subscribable().extend({rateLimit:500}); - var notifySpy = sinon.stub(); - subscribable.subscribe(notifySpy); - subscribable.subscribe(notifySpy, null, 'custom'); - - // "change" notification is delayed - subscribable.notifySubscribers('a', "change"); - expect(notifySpy.called).to.equal(false); - - // Default notification is delayed - subscribable.notifySubscribers('b'); - expect(notifySpy.called).to.equal(false); - - // Other notifications happen immediately - subscribable.notifySubscribers('c', "custom"); - expect(notifySpy.calledWith('c')).to.equal(true); - - // Advance clock; Change notification happens now using the latest value notified - notifySpy.resetHistory(); - clock.tick(500); - expect(notifySpy.calledWith('b')).to.equal(true); - }); - - it('Should notify every timeout interval using notifyAtFixedRate method ', function() { - var subscribable = new ko.subscribable().extend({rateLimit:{method:'notifyAtFixedRate', timeout:50}}); - var notifySpy = sinon.stub(); - subscribable.subscribe(notifySpy); - - // Push 10 changes every 25 ms - for (var i = 0; i < 10; ++i) { - subscribable.notifySubscribers(i+1); - clock.tick(25); +var clock + +describe('Throttled observables', function () { + beforeEach(function () { + clock = sinon.useFakeTimers() + }) // Workaround for spurious timing-related failures on IE8 (issue #736) + + afterEach(function () { + clock.restore() + clock = null + }) + + it('Should notify subscribers asynchronously after writes stop for the specified timeout duration', function () { + var observable = ko.observable('A').extend({ throttle: 100 }) + var notifiedValues = [] + observable.subscribe(function (value) { + notifiedValues.push(value) + }) + + // Mutate a few times + observable('B') + observable('C') + observable('D') + expect(notifiedValues.length).to.deep.equal(0) // Should not notify synchronously + + clock.tick(10) + + // Mutate more + observable('E') + observable('F') + expect(notifiedValues.length).to.deep.equal(0) // Should not notify until end of throttle timeout + + clock.tick(100) + expect(notifiedValues.length).to.deep.equal(1) + expect(notifiedValues[0]).to.deep.equal('F') + }) +}) + +describe('Throttled dependent observables', function () { + beforeEach(function () { + clock = sinon.useFakeTimers() + }) // Workaround for spurious timing-related failures on IE8 (issue #736) + + afterEach(function () { + clock.restore() + clock = null + }) + + it('Should notify subscribers asynchronously after dependencies stop updating for the specified timeout duration', function () { + var underlying = ko.observable() + var asyncDepObs = ko + .dependentObservable(function () { + return underlying() + }) + .extend({ throttle: 100 }) + var notifiedValues = [] + asyncDepObs.subscribe(function (value) { + notifiedValues.push(value) + }) + + // Check initial state + expect(asyncDepObs()).to.equal(undefined) + // Mutate + underlying('New value') + expect(asyncDepObs()).to.equal(undefined) // Should not update synchronously + expect(notifiedValues.length).to.deep.equal(0) + + // Still shouldn't have evaluated + clock.tick(10) + expect(asyncDepObs()).to.equal(undefined) // Should not update until throttle timeout + expect(notifiedValues.length).to.deep.equal(0) + + // Now wait for throttle timeout + clock.tick(100) + expect(asyncDepObs()).to.deep.equal('New value') + expect(notifiedValues.length).to.deep.equal(1) + expect(notifiedValues[0]).to.deep.equal('New value') + }) + + it('Should run evaluator only once when dependencies stop updating for the specified timeout duration', function () { + var evaluationCount = 0 + var someDependency = ko.observable() + var asyncDepObs = ko + .dependentObservable(function () { + evaluationCount++ + return someDependency() + }) + .extend({ throttle: 100 }) + + // Mutate a few times synchronously + expect(evaluationCount).to.deep.equal(1) // Evaluates synchronously when first created, like all dependent observables + someDependency('A') + someDependency('B') + someDependency('C') + expect(evaluationCount).to.deep.equal(1) // Should not re-evaluate synchronously when dependencies update + + // Also mutate async + clock.tick(10) + someDependency('D') + expect(evaluationCount).to.deep.equal(1) + + // Now wait for throttle timeout + clock.tick(100) + expect(evaluationCount).to.deep.equal(2) // Finally, it's evaluated + expect(asyncDepObs()).to.deep.equal('D') + }) +}) + +describe('Rate-limited', function () { + beforeEach(function () { + clock = sinon.useFakeTimers() + }) + + afterEach(function () { + clock.restore() + clock = null + }) + + describe('Subscribable', function () { + it('Should delay change notifications', function () { + var subscribable = new ko.subscribable().extend({ rateLimit: 500 }) + var notifySpy = sinon.stub() + subscribable.subscribe(notifySpy) + subscribable.subscribe(notifySpy, null, 'custom') + + // "change" notification is delayed + subscribable.notifySubscribers('a', 'change') + expect(notifySpy.called).to.equal(false) + + // Default notification is delayed + subscribable.notifySubscribers('b') + expect(notifySpy.called).to.equal(false) + + // Other notifications happen immediately + subscribable.notifySubscribers('c', 'custom') + expect(notifySpy.calledWith('c')).to.equal(true) + + // Advance clock; Change notification happens now using the latest value notified + notifySpy.resetHistory() + clock.tick(500) + expect(notifySpy.calledWith('b')).to.equal(true) + }) + + it('Should notify every timeout interval using notifyAtFixedRate method ', function () { + var subscribable = new ko.subscribable().extend({ rateLimit: { method: 'notifyAtFixedRate', timeout: 50 } }) + var notifySpy = sinon.stub() + subscribable.subscribe(notifySpy) + + // Push 10 changes every 25 ms + for (var i = 0; i < 10; ++i) { + subscribable.notifySubscribers(i + 1) + clock.tick(25) + } + + // Notification happens every 50 ms, so every other number is notified + expect(notifySpy.callCount).to.equal(5) + expect( + notifySpy.getCalls().map(function (call) { + return call.args + }) + ).to.deep.equal([[2], [4], [6], [8], [10]]) + + // No more notifications happen + notifySpy.resetHistory() + clock.tick(50) + expect(notifySpy.called).to.equal(false) + }) + + it('Should notify after nothing happens for the timeout period using notifyWhenChangesStop method', function () { + var subscribable = new ko.subscribable().extend({ rateLimit: { method: 'notifyWhenChangesStop', timeout: 50 } }) + var notifySpy = sinon.stub() + subscribable.subscribe(notifySpy) + + // Push 10 changes every 25 ms + for (var i = 0; i < 10; ++i) { + subscribable.notifySubscribers(i + 1) + clock.tick(25) + } + + // No notifications happen yet + expect(notifySpy.called).to.equal(false) + + // Notification happens after the timeout period + clock.tick(50) + expect(notifySpy.callCount).to.equal(1) + expect(notifySpy.calledWith(10)).to.equal(true) + }) + + it('Should use latest settings when applied multiple times', function () { + var subscribable = new ko.subscribable().extend({ rateLimit: 250 }).extend({ rateLimit: 500 }) + var notifySpy = sinon.stub() + subscribable.subscribe(notifySpy) + + subscribable.notifySubscribers('a') + + clock.tick(250) + expect(notifySpy.called).to.equal(false) + + clock.tick(250) + expect(notifySpy.calledWith('a')).to.equal(true) + }) + + it('Uses latest settings for future notification and previous settings for pending notification', function () { + // This test describes the current behavior for the given scenario but is not a contract for that + // behavior, which could change in the future if convenient. + var subscribable = new ko.subscribable().extend({ rateLimit: 250 }) + var notifySpy = sinon.stub() + subscribable.subscribe(notifySpy) + + subscribable.notifySubscribers('a') // Pending notification + + // Apply new setting and schedule new notification + subscribable = subscribable.extend({ rateLimit: 500 }) + subscribable.notifySubscribers('b') + + // First notification happens using original settings + clock.tick(250) + expect(notifySpy.calledWith('a')).to.equal(true) + + // Second notification happends using later settings + notifySpy.resetHistory() + clock.tick(250) + expect(notifySpy.calledWith('b')).to.equal(true) + }) + + it('Should return "[object Object]" with .toString', function () { + // Issue #2252: make sure .toString method does not throw error + expect(new ko.subscribable().toString()).to.equal('[object Object]') + }) + }) + + describe('Observable', function () { + it('Should delay change notifications', function () { + var observable = ko.observable().extend({ rateLimit: 500 }) + var notifySpy = sinon.stub() + observable.subscribe(notifySpy) + var beforeChangeSpy = sinon.stub().callsFake(function (value) { + expect(observable()).to.equal(value) + }) + observable.subscribe(beforeChangeSpy, null, 'beforeChange') + + // Observable is changed, but notification is delayed + observable('a') + expect(observable()).to.deep.equal('a') + expect(notifySpy.called).to.equal(false) + expect(beforeChangeSpy.calledWith(undefined)).to.equal(true) // beforeChange notification happens right away + + // Second change notification is also delayed + observable('b') + expect(notifySpy.called).to.equal(false) + + // Advance clock; Change notification happens now using the latest value notified + clock.tick(500) + expect(notifySpy.calledWith('b')).to.equal(true) + expect(beforeChangeSpy.callCount).to.equal(1) // Only one beforeChange notification + }) + + it('Should notify "spectator" subscribers whenever the value changes', function () { + var observable = new ko.observable('A').extend({ rateLimit: 500 }), + spectateSpy = sinon.stub(), + notifySpy = sinon.stub() + + observable.subscribe(spectateSpy, null, 'spectate') + observable.subscribe(notifySpy) + + expect(spectateSpy.called).to.equal(false) + expect(notifySpy.called).to.equal(false) + + observable('B') + expect(spectateSpy.calledWith('B')).to.equal(true) + observable('C') + expect(spectateSpy.calledWith('C')).to.equal(true) + + expect(notifySpy.called).to.equal(false) + clock.tick(500) + + // "spectate" was called for each new value + expect( + spectateSpy.getCalls().map(function (call) { + return call.args + }) + ).to.deep.equal([['B'], ['C']]) + // whereas "change" was only called for the final value + expect( + notifySpy.getCalls().map(function (call) { + return call.args + }) + ).to.deep.equal([['C']]) + }) + + it('Should suppress change notification when value is changed/reverted', function () { + var observable = ko.observable('original').extend({ rateLimit: 500 }) + var notifySpy = sinon.stub() + observable.subscribe(notifySpy) + var beforeChangeSpy = sinon.stub() + observable.subscribe(beforeChangeSpy, null, 'beforeChange') + + observable('new') // change value + expect(observable()).to.deep.equal('new') // access observable to make sure it really has the changed value + observable('original') // but then change it back + expect(notifySpy.called).to.equal(false) + clock.tick(500) + expect(notifySpy.called).to.equal(false) + + // Check that value is correct and notification hasn't happened + expect(observable()).to.deep.equal('original') + expect(notifySpy.called).to.equal(false) + + // Changing observable to a new value still works as expected + observable('new') + clock.tick(500) + expect(notifySpy.calledWith('new')).to.equal(true) + expect(beforeChangeSpy.calledWith('original')).to.equal(true) + expect(beforeChangeSpy.calledWith('new')).to.equal(false) + }) + + it('Should support notifications from nested update', function () { + var observable = ko.observable('a').extend({ rateLimit: 500 }) + var notifySpy = sinon.stub() + observable.subscribe(notifySpy) + + // Create a one-time subscription that will modify the observable + var updateSub = observable.subscribe(function () { + updateSub.dispose() + observable('z') + }) + + observable('b') + expect(notifySpy.called).to.equal(false) + expect(observable()).to.deep.equal('b') + + notifySpy.resetHistory() + clock.tick(500) + expect(notifySpy.calledWith('b')).to.equal(true) + expect(observable()).to.deep.equal('z') + + notifySpy.resetHistory() + clock.tick(500) + expect(notifySpy.calledWith('z')).to.equal(true) + }) + + it('Should suppress notifications when value is changed/reverted from nested update', function () { + var observable = ko.observable('a').extend({ rateLimit: 500 }) + var notifySpy = sinon.stub() + observable.subscribe(notifySpy) + + // Create a one-time subscription that will modify the observable and then revert the change + var updateSub = observable.subscribe(function (newValue) { + updateSub.dispose() + observable('z') + observable(newValue) + }) + + observable('b') + expect(notifySpy.called).to.equal(false) + expect(observable()).to.deep.equal('b') + + notifySpy.resetHistory() + clock.tick(500) + expect(notifySpy.calledWith('b')).to.equal(true) + expect(observable()).to.deep.equal('b') + + notifySpy.resetHistory() + clock.tick(500) + expect(notifySpy.called).to.equal(false) + }) + + it('Should not notify future subscribers', function () { + var observable = ko.observable('a').extend({ rateLimit: 500 }), + notifySpy1 = sinon.stub(), + notifySpy2 = sinon.stub(), + notifySpy3 = sinon.stub() + + observable.subscribe(notifySpy1) + observable('b') + observable.subscribe(notifySpy2) + observable('c') + observable.subscribe(notifySpy3) + + expect(notifySpy1.called).to.equal(false) + expect(notifySpy2.called).to.equal(false) + expect(notifySpy3.called).to.equal(false) + + clock.tick(500) + expect(notifySpy1.calledWith('c')).to.equal(true) + expect(notifySpy2.calledWith('c')).to.equal(true) + expect(notifySpy3.called).to.equal(false) + }) + + it('Should delay update of dependent computed observable', function () { + var observable = ko.observable().extend({ rateLimit: 500 }) + var computed = ko.computed(observable) + + // Check initial value + expect(computed()).to.equal(undefined) + + // Observable is changed, but computed is not + observable('a') + expect(observable()).to.deep.equal('a') + expect(computed()).to.equal(undefined) + + // Second change also + observable('b') + expect(computed()).to.equal(undefined) + + // Advance clock; Change notification happens now using the latest value notified + clock.tick(500) + expect(computed()).to.deep.equal('b') + }) + + it('Should delay update of dependent pure computed observable', function () { + var observable = ko.observable().extend({ rateLimit: 500 }) + var computed = ko.pureComputed(observable) + + // Check initial value + expect(computed()).to.equal(undefined) + + // Observable is changed, but computed is not + observable('a') + expect(observable()).to.deep.equal('a') + expect(computed()).to.equal(undefined) + + // Second change also + observable('b') + expect(computed()).to.equal(undefined) + + // Advance clock; Change notification happens now using the latest value notified + clock.tick(500) + expect(computed()).to.deep.equal('b') + }) + + it('Should not update dependent computed created after last update', function () { + var observable = ko.observable('a').extend({ rateLimit: 500 }) + observable('b') + + var evalSpy = sinon.stub() + var computed = ko.computed(function () { + return evalSpy(observable()) + }) + expect(evalSpy.calledWith('b')).to.equal(true) + evalSpy.resetHistory() + + clock.tick(500) + expect(evalSpy.called).to.equal(false) + }) + + it('Should not cause loss of updates when an intermediate value is read by a dependent computed observable', function () { + // From https://github.com/knockout/knockout/issues/1835 + var one = ko.observable(false).extend({ rateLimit: 100 }), + two = ko.observable(false), + three = ko.computed(function () { + return one() || two() + }), + threeNotifications = [] + + three.subscribe(function (val) { + threeNotifications.push(val) + }) + + // The loop shows that the same steps work continuously + for (var i = 0; i < 3; i++) { + expect(one() || two() || three()).to.deep.equal(false) + threeNotifications = [] + + one(true) + expect(threeNotifications).to.deep.equal([]) + two(true) + expect(threeNotifications).to.deep.equal([true]) + two(false) + expect(threeNotifications).to.deep.equal([true]) + one(false) + expect(threeNotifications).to.deep.equal([true]) + + clock.tick(100) + expect(threeNotifications).to.deep.equal([true, false]) + } + }) + }) + + describe('Observable Array change tracking', function () { + it('Should provide correct changelist when multiple updates are merged into one notification', function () { + var myArray = ko.observableArray(['Alpha', 'Beta']).extend({ rateLimit: 1 }), + changelist + + myArray.subscribe( + function (changes) { + changelist = changes + }, + null, + 'arrayChange' + ) + + myArray.push('Gamma') + myArray.push('Delta') + clock.tick(10) + expect(changelist).to.deep.equal([ + { status: 'added', value: 'Gamma', index: 2 }, + { status: 'added', value: 'Delta', index: 3 } + ]) + + changelist = undefined + myArray.shift() + myArray.shift() + clock.tick(10) + expect(changelist).to.deep.equal([ + { status: 'deleted', value: 'Alpha', index: 0 }, + { status: 'deleted', value: 'Beta', index: 1 } + ]) + + changelist = undefined + myArray.push('Epsilon') + myArray.pop() + clock.tick(10) + expect(changelist).to.deep.equal(undefined) + }) + }) + + describe('Computed Observable', function () { + it('Should delay running evaluator where there are no subscribers', function () { + var observable = ko.observable() + var evalSpy = sinon.stub() + var computed = ko + .computed(function () { + evalSpy(observable()) + return observable() + }) + .extend({ rateLimit: 500 }) + + // Observable is changed, but evaluation is delayed + evalSpy.resetHistory() + observable('a') + observable('b') + expect(evalSpy.called).to.equal(false) + + // Advance clock; Change notification happens now using the latest value notified + evalSpy.resetHistory() + clock.tick(500) + expect(evalSpy.calledWith('b')).to.equal(true) + }) + + it('Should delay change notifications and evaluation', function () { + var observable = ko.observable() + var evalSpy = sinon.stub() + var computed = ko + .computed(function () { + evalSpy(observable()) + return observable() + }) + .extend({ rateLimit: 500 }) + var notifySpy = sinon.stub() + computed.subscribe(notifySpy) + var beforeChangeSpy = sinon.stub().callsFake(function (value) { + expect(computed()).to.equal(value) + }) + computed.subscribe(beforeChangeSpy, null, 'beforeChange') + + // Observable is changed, but notification is delayed + evalSpy.resetHistory() + observable('a') + expect(evalSpy.called).to.equal(false) + expect(computed()).to.deep.equal('a') + expect(evalSpy.calledWith('a')).to.equal(true) // evaluation happens when computed is accessed + expect(notifySpy.called).to.equal(false) // but notification is still delayed + expect(beforeChangeSpy.calledWith(undefined)).to.equal(true) // beforeChange notification happens right away + + // Second change notification is also delayed + evalSpy.resetHistory() + observable('b') + expect(computed.peek()).to.deep.equal('a') // peek returns previously evaluated value + expect(evalSpy.called).to.equal(false) + expect(notifySpy.called).to.equal(false) + + // Advance clock; Change notification happens now using the latest value notified + evalSpy.resetHistory() + clock.tick(500) + expect(evalSpy.calledWith('b')).to.equal(true) + expect(notifySpy.calledWith('b')).to.equal(true) + expect(beforeChangeSpy.callCount).to.equal(1) // Only one beforeChange notification + }) + + it('Should run initial evaluation at first subscribe when using deferEvaluation', function () { + // This behavior means that code using rate-limited computeds doesn't need to care if the + // computed also has deferEvaluation. For example, the preceding test ('Should delay change + // notifications and evaluation') will pass just as well if using deferEvaluation. + var observable = ko.observable('a') + var evalSpy = sinon.stub() + var computed = ko + .computed({ + read: function () { + evalSpy(observable()) + return observable() + }, + deferEvaluation: true + }) + .extend({ rateLimit: 500 }) + expect(evalSpy.called).to.equal(false) + + var notifySpy = sinon.stub() + computed.subscribe(notifySpy) + expect(evalSpy.calledWith('a')).to.equal(true) + expect(notifySpy.called).to.equal(false) + }) + + it('Should run initial evaluation when observable is accessed when using deferEvaluation', function () { + var observable = ko.observable('a') + var evalSpy = sinon.stub() + var computed = ko + .computed({ + read: function () { + evalSpy(observable()) + return observable() + }, + deferEvaluation: true + }) + .extend({ rateLimit: 500 }) + expect(evalSpy.called).to.equal(false) + + expect(computed()).to.deep.equal('a') + expect(evalSpy.calledWith('a')).to.equal(true) + }) + + it('Should suppress change notifications when value is changed/reverted', function () { + var observable = ko.observable('original') + var computed = ko + .computed(function () { + return observable() + }) + .extend({ rateLimit: 500 }) + var notifySpy = sinon.stub() + computed.subscribe(notifySpy) + var beforeChangeSpy = sinon.stub() + computed.subscribe(beforeChangeSpy, null, 'beforeChange') + + observable('new') // change value + expect(computed()).to.deep.equal('new') // access computed to make sure it really has the changed value + observable('original') // and then change the value back + expect(notifySpy.called).to.equal(false) + clock.tick(500) + expect(notifySpy.called).to.equal(false) + + // Check that value is correct and notification hasn't happened + expect(computed()).to.deep.equal('original') + expect(notifySpy.called).to.equal(false) + + // Changing observable to a new value still works as expected + observable('new') + clock.tick(500) + expect(notifySpy.calledWith('new')).to.equal(true) + expect(beforeChangeSpy.calledWith('original')).to.equal(true) + expect(beforeChangeSpy.calledWith('new')).to.equal(false) + }) + + it('Should not re-evaluate if computed is disposed before timeout', function () { + var observable = ko.observable('a') + var evalSpy = sinon.stub() + var computed = ko + .computed(function () { + evalSpy(observable()) + return observable() + }) + .extend({ rateLimit: 500 }) + + expect(computed()).to.deep.equal('a') + expect(evalSpy.callCount).to.equal(1) + expect(evalSpy.calledWith('a')).to.equal(true) + + evalSpy.resetHistory() + observable('b') + computed.dispose() + + clock.tick(500) + expect(computed()).to.deep.equal('a') + expect(evalSpy.called).to.equal(false) + }) + + it('Should be able to re-evaluate a computed that previously threw an exception', function () { + var observableSwitch = ko.observable(true), + observableValue = ko.observable(1), + computed = ko + .computed(function () { + if (!observableSwitch()) { + throw Error('Error during computed evaluation') + } else { + return observableValue() } - - // Notification happens every 50 ms, so every other number is notified - expect(notifySpy.callCount).to.equal(5); - expect(notifySpy.getCalls().map(function(call) { return call.args; })).to.deep.equal([ [2], [4], [6], [8], [10] ]); - - // No more notifications happen - notifySpy.resetHistory(); - clock.tick(50); - expect(notifySpy.called).to.equal(false); - }); - - it('Should notify after nothing happens for the timeout period using notifyWhenChangesStop method', function() { - var subscribable = new ko.subscribable().extend({rateLimit:{method:'notifyWhenChangesStop', timeout:50}}); - var notifySpy = sinon.stub(); - subscribable.subscribe(notifySpy); - - // Push 10 changes every 25 ms - for (var i = 0; i < 10; ++i) { - subscribable.notifySubscribers(i+1); - clock.tick(25); - } - - // No notifications happen yet - expect(notifySpy.called).to.equal(false); - - // Notification happens after the timeout period - clock.tick(50); - expect(notifySpy.callCount).to.equal(1); - expect(notifySpy.calledWith(10)).to.equal(true); - }); - - it('Should use latest settings when applied multiple times', function() { - var subscribable = new ko.subscribable().extend({rateLimit:250}).extend({rateLimit:500}); - var notifySpy = sinon.stub(); - subscribable.subscribe(notifySpy); - - subscribable.notifySubscribers('a'); - - clock.tick(250); - expect(notifySpy.called).to.equal(false); - - clock.tick(250); - expect(notifySpy.calledWith('a')).to.equal(true); - }); - - it('Uses latest settings for future notification and previous settings for pending notification', function() { - // This test describes the current behavior for the given scenario but is not a contract for that - // behavior, which could change in the future if convenient. - var subscribable = new ko.subscribable().extend({rateLimit:250}); - var notifySpy = sinon.stub(); - subscribable.subscribe(notifySpy); - - subscribable.notifySubscribers('a'); // Pending notification - - // Apply new setting and schedule new notification - subscribable = subscribable.extend({rateLimit:500}); - subscribable.notifySubscribers('b'); - - // First notification happens using original settings - clock.tick(250); - expect(notifySpy.calledWith('a')).to.equal(true); - - // Second notification happends using later settings - notifySpy.resetHistory(); - clock.tick(250); - expect(notifySpy.calledWith('b')).to.equal(true); - }); - - it('Should return "[object Object]" with .toString', function() { - // Issue #2252: make sure .toString method does not throw error - expect(new ko.subscribable().toString()).to.equal('[object Object]') - }); - }); - - describe('Observable', function() { - it('Should delay change notifications', function() { - var observable = ko.observable().extend({rateLimit:500}); - var notifySpy = sinon.stub(); - observable.subscribe(notifySpy); - var beforeChangeSpy = sinon.stub() - .callsFake(function(value) {expect(observable()).to.equal(value); }); - observable.subscribe(beforeChangeSpy, null, 'beforeChange'); - - // Observable is changed, but notification is delayed - observable('a'); - expect(observable()).to.deep.equal('a'); - expect(notifySpy.called).to.equal(false); - expect(beforeChangeSpy.calledWith(undefined)).to.equal(true); // beforeChange notification happens right away - - // Second change notification is also delayed - observable('b'); - expect(notifySpy.called).to.equal(false); - - // Advance clock; Change notification happens now using the latest value notified - clock.tick(500); - expect(notifySpy.calledWith('b')).to.equal(true); - expect(beforeChangeSpy.callCount).to.equal(1); // Only one beforeChange notification - }); - - it('Should notify "spectator" subscribers whenever the value changes', function () { - var observable = new ko.observable('A').extend({rateLimit:500}), - spectateSpy = sinon.stub(), - notifySpy = sinon.stub(); - - observable.subscribe(spectateSpy, null, "spectate"); - observable.subscribe(notifySpy); - - expect(spectateSpy.called).to.equal(false); - expect(notifySpy.called).to.equal(false); - - observable('B'); - expect(spectateSpy.calledWith('B')).to.equal(true); - observable('C'); - expect(spectateSpy.calledWith('C')).to.equal(true); - - expect(notifySpy.called).to.equal(false); - clock.tick(500); - - // "spectate" was called for each new value - expect(spectateSpy.getCalls().map(function(call) { return call.args; })).to.deep.equal([ ['B'], ['C'] ]); - // whereas "change" was only called for the final value - expect(notifySpy.getCalls().map(function(call) { return call.args; })).to.deep.equal([ ['C'] ]); - }); - - it('Should suppress change notification when value is changed/reverted', function() { - var observable = ko.observable('original').extend({rateLimit:500}); - var notifySpy = sinon.stub(); - observable.subscribe(notifySpy); - var beforeChangeSpy = sinon.stub(); - observable.subscribe(beforeChangeSpy, null, 'beforeChange'); - - observable('new'); // change value - expect(observable()).to.deep.equal('new'); // access observable to make sure it really has the changed value - observable('original'); // but then change it back - expect(notifySpy.called).to.equal(false); - clock.tick(500); - expect(notifySpy.called).to.equal(false); - - // Check that value is correct and notification hasn't happened - expect(observable()).to.deep.equal('original'); - expect(notifySpy.called).to.equal(false); - - // Changing observable to a new value still works as expected - observable('new'); - clock.tick(500); - expect(notifySpy.calledWith('new')).to.equal(true); - expect(beforeChangeSpy.calledWith('original')).to.equal(true); - expect(beforeChangeSpy.calledWith('new')).to.equal(false); - }); - - it('Should support notifications from nested update', function() { - var observable = ko.observable('a').extend({rateLimit:500}); - var notifySpy = sinon.stub(); - observable.subscribe(notifySpy); - - // Create a one-time subscription that will modify the observable - var updateSub = observable.subscribe(function() { - updateSub.dispose(); - observable('z'); - }); - - observable('b'); - expect(notifySpy.called).to.equal(false); - expect(observable()).to.deep.equal('b'); - - notifySpy.resetHistory(); - clock.tick(500); - expect(notifySpy.calledWith('b')).to.equal(true); - expect(observable()).to.deep.equal('z'); - - notifySpy.resetHistory(); - clock.tick(500); - expect(notifySpy.calledWith('z')).to.equal(true); - }); - - it('Should suppress notifications when value is changed/reverted from nested update', function() { - var observable = ko.observable('a').extend({rateLimit:500}); - var notifySpy = sinon.stub(); - observable.subscribe(notifySpy); - - // Create a one-time subscription that will modify the observable and then revert the change - var updateSub = observable.subscribe(function(newValue) { - updateSub.dispose(); - observable('z'); - observable(newValue); - }); - - observable('b'); - expect(notifySpy.called).to.equal(false); - expect(observable()).to.deep.equal('b'); - - notifySpy.resetHistory(); - clock.tick(500); - expect(notifySpy.calledWith('b')).to.equal(true); - expect(observable()).to.deep.equal('b'); - - notifySpy.resetHistory(); - clock.tick(500); - expect(notifySpy.called).to.equal(false); - }); - - it('Should not notify future subscribers', function() { - var observable = ko.observable('a').extend({rateLimit:500}), - notifySpy1 = sinon.stub(), - notifySpy2 = sinon.stub(), - notifySpy3 = sinon.stub(); - - observable.subscribe(notifySpy1); - observable('b'); - observable.subscribe(notifySpy2); - observable('c'); - observable.subscribe(notifySpy3); - - expect(notifySpy1.called).to.equal(false); - expect(notifySpy2.called).to.equal(false); - expect(notifySpy3.called).to.equal(false); - - clock.tick(500); - expect(notifySpy1.calledWith('c')).to.equal(true); - expect(notifySpy2.calledWith('c')).to.equal(true); - expect(notifySpy3.called).to.equal(false); - }); - - it('Should delay update of dependent computed observable', function() { - var observable = ko.observable().extend({rateLimit:500}); - var computed = ko.computed(observable); - - // Check initial value - expect(computed()).to.equal(undefined); - - // Observable is changed, but computed is not - observable('a'); - expect(observable()).to.deep.equal('a'); - expect(computed()).to.equal(undefined); - - // Second change also - observable('b'); - expect(computed()).to.equal(undefined); - - // Advance clock; Change notification happens now using the latest value notified - clock.tick(500); - expect(computed()).to.deep.equal('b'); - }); - - it('Should delay update of dependent pure computed observable', function() { - var observable = ko.observable().extend({rateLimit:500}); - var computed = ko.pureComputed(observable); - - // Check initial value - expect(computed()).to.equal(undefined); - - // Observable is changed, but computed is not - observable('a'); - expect(observable()).to.deep.equal('a'); - expect(computed()).to.equal(undefined); - - // Second change also - observable('b'); - expect(computed()).to.equal(undefined); - - // Advance clock; Change notification happens now using the latest value notified - clock.tick(500); - expect(computed()).to.deep.equal('b'); - }); - - it('Should not update dependent computed created after last update', function() { - var observable = ko.observable('a').extend({rateLimit:500}); - observable('b'); - - var evalSpy = sinon.stub(); - var computed = ko.computed(function () { - return evalSpy(observable()); - }); - expect(evalSpy.calledWith('b')).to.equal(true); - evalSpy.resetHistory(); - - clock.tick(500); - expect(evalSpy.called).to.equal(false); - }); - - - it('Should not cause loss of updates when an intermediate value is read by a dependent computed observable', function() { - // From https://github.com/knockout/knockout/issues/1835 - var one = ko.observable(false).extend({rateLimit: 100}), - two = ko.observable(false), - three = ko.computed(function() { return one() || two(); }), - threeNotifications = []; - - three.subscribe(function(val) { - threeNotifications.push(val); - }); - - // The loop shows that the same steps work continuously - for (var i = 0; i < 3; i++) { - expect(one() || two() || three()).to.deep.equal(false); - threeNotifications = []; - - one(true); - expect(threeNotifications).to.deep.equal([]); - two(true); - expect(threeNotifications).to.deep.equal([true]); - two(false); - expect(threeNotifications).to.deep.equal([true]); - one(false); - expect(threeNotifications).to.deep.equal([true]); - - clock.tick(100); - expect(threeNotifications).to.deep.equal([true, false]); - } - }); - }); - - describe('Observable Array change tracking', function() { - it('Should provide correct changelist when multiple updates are merged into one notification', function() { - var myArray = ko.observableArray(['Alpha', 'Beta']).extend({rateLimit:1}), - changelist; - - myArray.subscribe(function(changes) { - changelist = changes; - }, null, 'arrayChange'); - - myArray.push('Gamma'); - myArray.push('Delta'); - clock.tick(10); - expect(changelist).to.deep.equal([ - { status : 'added', value : 'Gamma', index : 2 }, - { status : 'added', value : 'Delta', index : 3 } - ]); - - changelist = undefined; - myArray.shift(); - myArray.shift(); - clock.tick(10); - expect(changelist).to.deep.equal([ - { status : 'deleted', value : 'Alpha', index : 0 }, - { status : 'deleted', value : 'Beta', index : 1 } - ]); - - changelist = undefined; - myArray.push('Epsilon'); - myArray.pop(); - clock.tick(10); - expect(changelist).to.deep.equal(undefined); - }); - }); - - describe('Computed Observable', function() { - it('Should delay running evaluator where there are no subscribers', function() { - var observable = ko.observable(); - var evalSpy = sinon.stub(); - var computed = ko.computed(function () { evalSpy(observable()); return observable(); }).extend({rateLimit:500}); - - // Observable is changed, but evaluation is delayed - evalSpy.resetHistory(); - observable('a'); - observable('b'); - expect(evalSpy.called).to.equal(false); - - // Advance clock; Change notification happens now using the latest value notified - evalSpy.resetHistory(); - clock.tick(500); - expect(evalSpy.calledWith('b')).to.equal(true); - }); - - it('Should delay change notifications and evaluation', function() { - var observable = ko.observable(); - var evalSpy = sinon.stub(); - var computed = ko.computed(function () { evalSpy(observable()); return observable(); }).extend({rateLimit:500}); - var notifySpy = sinon.stub(); - computed.subscribe(notifySpy); - var beforeChangeSpy = sinon.stub() - .callsFake(function(value) {expect(computed()).to.equal(value); }); - computed.subscribe(beforeChangeSpy, null, 'beforeChange'); - - // Observable is changed, but notification is delayed - evalSpy.resetHistory(); - observable('a'); - expect(evalSpy.called).to.equal(false); - expect(computed()).to.deep.equal('a'); - expect(evalSpy.calledWith('a')).to.equal(true); // evaluation happens when computed is accessed - expect(notifySpy.called).to.equal(false); // but notification is still delayed - expect(beforeChangeSpy.calledWith(undefined)).to.equal(true); // beforeChange notification happens right away - - // Second change notification is also delayed - evalSpy.resetHistory(); - observable('b'); - expect(computed.peek()).to.deep.equal('a'); // peek returns previously evaluated value - expect(evalSpy.called).to.equal(false); - expect(notifySpy.called).to.equal(false); - - // Advance clock; Change notification happens now using the latest value notified - evalSpy.resetHistory(); - clock.tick(500); - expect(evalSpy.calledWith('b')).to.equal(true); - expect(notifySpy.calledWith('b')).to.equal(true); - expect(beforeChangeSpy.callCount).to.equal(1); // Only one beforeChange notification - }); - - it('Should run initial evaluation at first subscribe when using deferEvaluation', function() { - // This behavior means that code using rate-limited computeds doesn't need to care if the - // computed also has deferEvaluation. For example, the preceding test ('Should delay change - // notifications and evaluation') will pass just as well if using deferEvaluation. - var observable = ko.observable('a'); - var evalSpy = sinon.stub(); - var computed = ko.computed({ - read: function () { - evalSpy(observable()); - return observable(); - }, - deferEvaluation: true - }).extend({rateLimit:500}); - expect(evalSpy.called).to.equal(false); - - var notifySpy = sinon.stub(); - computed.subscribe(notifySpy); - expect(evalSpy.calledWith('a')).to.equal(true); - expect(notifySpy.called).to.equal(false); - }); - - it('Should run initial evaluation when observable is accessed when using deferEvaluation', function() { - var observable = ko.observable('a'); - var evalSpy = sinon.stub(); - var computed = ko.computed({ - read: function () { - evalSpy(observable()); - return observable(); - }, - deferEvaluation: true - }).extend({rateLimit:500}); - expect(evalSpy.called).to.equal(false); - - expect(computed()).to.deep.equal('a'); - expect(evalSpy.calledWith('a')).to.equal(true); - }); - - it('Should suppress change notifications when value is changed/reverted', function() { - var observable = ko.observable('original'); - var computed = ko.computed(function () { return observable(); }).extend({rateLimit:500}); - var notifySpy = sinon.stub(); - computed.subscribe(notifySpy); - var beforeChangeSpy = sinon.stub(); - computed.subscribe(beforeChangeSpy, null, 'beforeChange'); - - observable('new'); // change value - expect(computed()).to.deep.equal('new'); // access computed to make sure it really has the changed value - observable('original'); // and then change the value back - expect(notifySpy.called).to.equal(false); - clock.tick(500); - expect(notifySpy.called).to.equal(false); - - // Check that value is correct and notification hasn't happened - expect(computed()).to.deep.equal('original'); - expect(notifySpy.called).to.equal(false); - - // Changing observable to a new value still works as expected - observable('new'); - clock.tick(500); - expect(notifySpy.calledWith('new')).to.equal(true); - expect(beforeChangeSpy.calledWith('original')).to.equal(true); - expect(beforeChangeSpy.calledWith('new')).to.equal(false); - }); - - it('Should not re-evaluate if computed is disposed before timeout', function() { - var observable = ko.observable('a'); - var evalSpy = sinon.stub(); - var computed = ko.computed(function () { evalSpy(observable()); return observable(); }).extend({rateLimit:500}); - - expect(computed()).to.deep.equal('a'); - expect(evalSpy.callCount).to.equal(1); - expect(evalSpy.calledWith('a')).to.equal(true); - - evalSpy.resetHistory(); - observable('b'); - computed.dispose(); - - clock.tick(500); - expect(computed()).to.deep.equal('a'); - expect(evalSpy.called).to.equal(false); - }); - - it('Should be able to re-evaluate a computed that previously threw an exception', function() { - var observableSwitch = ko.observable(true), observableValue = ko.observable(1), - computed = ko.computed(function() { - if (!observableSwitch()) { - throw Error("Error during computed evaluation"); - } else { - return observableValue(); - } - }).extend({rateLimit:500}); - - // Initially the computed evaluated successfully - expect(computed()).to.deep.equal(1); - - expect(function () { - // Update observable to cause computed to throw an exception - observableSwitch(false); - computed(); - }).to.throw("Error during computed evaluation"); - - // The value of the computed is now undefined, although currently it keeps the previous value - // This should not try to re-evaluate and thus shouldn't throw an exception - expect(computed()).to.deep.equal(1); - // The computed should not be dependent on the second observable - expect(computed.getDependenciesCount()).to.deep.equal(1); - expect(computed.getDependencies()).to.deep.equal([observableSwitch]); - - // Updating the second observable shouldn't re-evaluate computed - observableValue(2); - expect(computed()).to.deep.equal(1); - - // Update the first observable to cause computed to re-evaluate - observableSwitch(1); - expect(computed()).to.deep.equal(2); - }); - - it('Should delay update of dependent computed observable', function() { - var observable = ko.observable(); - var rateLimitComputed = ko.computed(observable).extend({rateLimit:500}); - var dependentComputed = ko.computed(rateLimitComputed); - - // Check initial value - expect(dependentComputed()).to.equal(undefined); - - // Rate-limited computed is changed, but dependent computed is not - observable('a'); - expect(rateLimitComputed()).to.deep.equal('a'); - expect(dependentComputed()).to.equal(undefined); - - // Second change also - observable('b'); - expect(dependentComputed()).to.equal(undefined); - - // Advance clock; Change notification happens now using the latest value notified - clock.tick(500); - expect(dependentComputed()).to.deep.equal('b'); - }); - - it('Should delay update of dependent pure computed observable', function() { - var observable = ko.observable(); - var rateLimitComputed = ko.computed(observable).extend({rateLimit:500}); - var dependentComputed = ko.pureComputed(rateLimitComputed); - - // Check initial value - expect(dependentComputed()).to.equal(undefined); - - // Rate-limited computed is changed, but dependent computed is not - observable('a'); - expect(rateLimitComputed()).to.deep.equal('a'); - expect(dependentComputed()).to.equal(undefined); - - // Second change also - observable('b'); - expect(dependentComputed()).to.equal(undefined); - - // Advance clock; Change notification happens now using the latest value notified - clock.tick(500); - expect(dependentComputed()).to.deep.equal('b'); - }); - - it('Should not cause loss of updates when an intermediate value is read by a dependent computed observable', function() { - // From https://github.com/knockout/knockout/issues/1835 - var one = ko.observable(false), - onePointOne = ko.computed(one).extend({rateLimit: 100}), - two = ko.observable(false), - three = ko.computed(function() { return onePointOne() || two(); }), - threeNotifications = []; - - three.subscribe(function(val) { - threeNotifications.push(val); - }); - - // The loop shows that the same steps work continuously - for (var i = 0; i < 3; i++) { - expect(onePointOne() || two() || three()).to.deep.equal(false); - threeNotifications = []; - - one(true); - expect(threeNotifications).to.deep.equal([]); - two(true); - expect(threeNotifications).to.deep.equal([true]); - two(false); - expect(threeNotifications).to.deep.equal([true]); - one(false); - expect(threeNotifications).to.deep.equal([true]); - - clock.tick(100); - expect(threeNotifications).to.deep.equal([true, false]); - } - }); - }); -}); - -describe('Deferred', function() { - beforeEach(function() { - clock = sinon.useFakeTimers(); - restoreAfter(ko.options, 'taskScheduler'); - restoreAfter(ko.tasks, 'scheduler'); - ko.options.taskScheduler = function(callback) { - setTimeout(callback, 0); - }; - ko.tasks.scheduler = function(callback) { - setTimeout(callback, 0); - }; - }); - - afterEach(function() { - expect(ko.tasks.resetForTesting()).to.deep.equal(0); - clock.restore(); - clock = null; - }); - - describe('Observable', function() { - it('Should delay notifications', function() { - var observable = ko.observable().extend({deferred:true}); - var notifySpy = sinon.stub(); - observable.subscribe(notifySpy); - - observable('A'); - expect(notifySpy.called).to.equal(false); - - clock.tick(1); - expect(notifySpy.getCalls().map(function(call) { return call.args; })).to.deep.equal([ ['A'] ]); - }); - - it('Should throw if you attempt to turn off deferred', function() { - // As of commit 6d5d786, the 'deferred' option cannot be deactivated (once activated for - // a given observable). - var observable = ko.observable(); - - observable.extend({deferred: true}); - expect(function() { - observable.extend({deferred: false}); - }).to.throw('The \'deferred\' extender only accepts the value \'true\', because it is not supported to turn deferral off once enabled.'); - }); - - it('Should notify subscribers about only latest value', function() { - var observable = ko.observable().extend({notify:'always', deferred:true}); // include notify:'always' to ensure notifications weren't suppressed by some other means - var notifySpy = sinon.stub(); - observable.subscribe(notifySpy); - - observable('A'); - observable('B'); - - clock.tick(1); - expect(notifySpy.getCalls().map(function(call) { return call.args; })).to.deep.equal([ ['B'] ]); - }); - - it('Should suppress notification when value is changed/reverted', function() { - var observable = ko.observable('original').extend({deferred:true}); - var notifySpy = sinon.stub(); - observable.subscribe(notifySpy); - - observable('new'); - expect(observable()).to.deep.equal('new'); - observable('original'); - - clock.tick(1); - expect(notifySpy.called).to.equal(false); - expect(observable()).to.deep.equal('original'); - }); - - it('Should not notify future subscribers', function() { - var observable = ko.observable('a').extend({deferred:true}), - notifySpy1 = sinon.stub(), - notifySpy2 = sinon.stub(), - notifySpy3 = sinon.stub(); - - observable.subscribe(notifySpy1); - observable('b'); - observable.subscribe(notifySpy2); - observable('c'); - observable.subscribe(notifySpy3); - - expect(notifySpy1.called).to.equal(false); - expect(notifySpy2.called).to.equal(false); - expect(notifySpy3.called).to.equal(false); - - clock.tick(1); - expect(notifySpy1.calledWith('c')).to.equal(true); - expect(notifySpy2.calledWith('c')).to.equal(true); - expect(notifySpy3.called).to.equal(false); - }); - - it('Should not update dependent computed created after last update', function() { - var observable = ko.observable('a').extend({deferred:true}); - observable('b'); - - var evalSpy = sinon.stub(); - var computed = ko.computed(function () { - return evalSpy(observable()); - }); - expect(evalSpy.calledWith('b')).to.equal(true); - evalSpy.resetHistory(); - - clock.tick(1); - expect(evalSpy.called).to.equal(false); - }); - - it('Is default behavior when "ko.options.deferUpdates" is "true"', function() { - restoreAfter(ko.options, 'deferUpdates'); - ko.options.deferUpdates = true; - - var observable = ko.observable(); - var notifySpy = sinon.stub(); - observable.subscribe(notifySpy); - - observable('A'); - expect(notifySpy.called).to.equal(false); - - clock.tick(1); - expect(notifySpy.getCalls().map(function(call) { return call.args; })).to.deep.equal([ ['A'] ]); - }); - }); - - describe('Observable Array change tracking', function() { - it('Should provide correct changelist when multiple updates are merged into one notification', function() { - var myArray = ko.observableArray(['Alpha', 'Beta']).extend({deferred:true}), - changelist; - - myArray.subscribe(function(changes) { - changelist = changes; - }, null, 'arrayChange'); - - myArray.push('Gamma'); - myArray.push('Delta'); - clock.tick(1); - expect(changelist).to.deep.equal([ - { status : 'added', value : 'Gamma', index : 2 }, - { status : 'added', value : 'Delta', index : 3 } - ]); - - changelist = undefined; - myArray.shift(); - myArray.shift(); - clock.tick(1); - expect(changelist).to.deep.equal([ - { status : 'deleted', value : 'Alpha', index : 0 }, - { status : 'deleted', value : 'Beta', index : 1 } - ]); - - changelist = undefined; - myArray.push('Epsilon'); - myArray.pop(); - clock.tick(1); - expect(changelist).to.deep.equal(undefined); - }); - }); - - describe('Computed Observable', function() { - it('Should defer notification of changes and minimize evaluation', function () { - var timesEvaluated = 0, - data = ko.observable('A'), - computed = ko.computed(function () { ++timesEvaluated; return data(); }).extend({deferred:true}), - notifySpy = sinon.stub(), - subscription = computed.subscribe(notifySpy); - - expect(computed()).to.deep.equal('A'); - expect(timesEvaluated).to.deep.equal(1); - clock.tick(1); - expect(notifySpy.called).to.equal(false); - - data('B'); - expect(timesEvaluated).to.deep.equal(1); // not immediately evaluated - expect(computed()).to.deep.equal('B'); - expect(timesEvaluated).to.deep.equal(2); - expect(notifySpy.called).to.equal(false); - - clock.tick(1); - expect(notifySpy.callCount).to.deep.equal(1); - expect(notifySpy.getCalls().map(function(call) { return call.args; })).to.deep.equal([ ['B'] ]); - }); - - it('Should notify first change of computed with deferEvaluation if value is changed to undefined', function () { - var data = ko.observable('A'), - computed = ko.computed(data, null, {deferEvaluation: true}).extend({deferred:true}), - notifySpy = sinon.stub(), - subscription = computed.subscribe(notifySpy); - - expect(computed()).to.deep.equal('A'); - - data(undefined); - expect(computed()).to.deep.equal(undefined); - expect(notifySpy.called).to.equal(false); - - clock.tick(1); - expect(notifySpy.callCount).to.deep.equal(1); - expect(notifySpy.getCalls().map(function(call) { return call.args; })).to.deep.equal([ [undefined] ]); - }); - - it('Should notify first change to pure computed after awakening if value changed to last notified value', function() { - var data = ko.observable('A'), - computed = ko.pureComputed(data).extend({deferred:true}), - notifySpy = sinon.stub(), - subscription = computed.subscribe(notifySpy); - - data('B'); - expect(computed()).to.deep.equal('B'); - expect(notifySpy.called).to.equal(false); - clock.tick(1); - expect(notifySpy.getCalls().map(function(call) { return call.args; })).to.deep.equal([ ['B'] ]); - - subscription.dispose(); - notifySpy.resetHistory(); - data('C'); - expect(computed()).to.deep.equal('C'); - clock.tick(1); - expect(notifySpy.called).to.equal(false); - - subscription = computed.subscribe(notifySpy); - data('B'); - expect(computed()).to.deep.equal('B'); - expect(notifySpy.called).to.equal(false); - clock.tick(1); - expect(notifySpy.getCalls().map(function(call) { return call.args; })).to.deep.equal([ ['B'] ]); - }); - - it('Should delay update of dependent computed observable', function() { - var data = ko.observable('A'), - deferredComputed = ko.computed(data).extend({deferred:true}), - dependentComputed = ko.computed(deferredComputed); - - expect(dependentComputed()).to.deep.equal('A'); - - data('B'); - expect(deferredComputed()).to.deep.equal('B'); - expect(dependentComputed()).to.deep.equal('A'); - - data('C'); - expect(dependentComputed()).to.deep.equal('A'); - - clock.tick(1); - expect(dependentComputed()).to.deep.equal('C'); - }); - - it('Should delay update of dependent pure computed observable', function() { - var data = ko.observable('A'), - deferredComputed = ko.computed(data).extend({deferred:true}), - dependentComputed = ko.pureComputed(deferredComputed); - - expect(dependentComputed()).to.deep.equal('A'); - - data('B'); - expect(deferredComputed()).to.deep.equal('B'); - expect(dependentComputed()).to.deep.equal('A'); - - data('C'); - expect(dependentComputed()).to.deep.equal('A'); - - clock.tick(1); - expect(dependentComputed()).to.deep.equal('C'); - }); - - it('Should *not* delay update of dependent deferred computed observable', function () { - var data = ko.observable('A').extend({deferred:true}), - timesEvaluated = 0, - computed1 = ko.computed(function () { return data() + 'X'; }).extend({deferred:true}), - computed2 = ko.computed(function () { timesEvaluated++; return computed1() + 'Y'; }).extend({deferred:true}), - notifySpy = sinon.stub(), - subscription = computed2.subscribe(notifySpy); - - expect(computed2()).to.deep.equal('AXY'); - expect(timesEvaluated).to.deep.equal(1); - - data('B'); - expect(computed2()).to.deep.equal('BXY'); - expect(timesEvaluated).to.deep.equal(2); - expect(notifySpy.called).to.equal(false); - - clock.tick(1); - expect(computed2()).to.deep.equal('BXY'); - expect(timesEvaluated).to.deep.equal(2); // Verify that the computed wasn't evaluated again unnecessarily - expect(notifySpy.getCalls().map(function(call) { return call.args; })).to.deep.equal([ ['BXY'] ]); - }); - - it('Should *not* delay update of dependent deferred pure computed observable', function () { - var data = ko.observable('A').extend({deferred:true}), - timesEvaluated = 0, - computed1 = ko.pureComputed(function () { return data() + 'X'; }).extend({deferred:true}), - computed2 = ko.pureComputed(function () { timesEvaluated++; return computed1() + 'Y'; }).extend({deferred:true}); - - expect(computed2()).to.deep.equal('AXY'); - expect(timesEvaluated).to.deep.equal(1); - - data('B'); - expect(computed2()).to.deep.equal('BXY'); - expect(timesEvaluated).to.deep.equal(2); - - clock.tick(1); - expect(computed2()).to.deep.equal('BXY'); - expect(timesEvaluated).to.deep.equal(2); // Verify that the computed wasn't evaluated again unnecessarily - }); - - it('Should *not* delay update of dependent rate-limited computed observable', function() { - var data = ko.observable('A'), - deferredComputed = ko.computed(data).extend({deferred:true}), - dependentComputed = ko.computed(deferredComputed).extend({rateLimit: 500}), - notifySpy = sinon.stub(), - subscription = dependentComputed.subscribe(notifySpy); - - expect(dependentComputed()).to.deep.equal('A'); - - data('B'); - expect(deferredComputed()).to.deep.equal('B'); - expect(dependentComputed()).to.deep.equal('B'); - - data('C'); - expect(dependentComputed()).to.deep.equal('C'); - expect(notifySpy.called).to.equal(false); - - clock.tick(500); - expect(dependentComputed()).to.deep.equal('C'); - expect(notifySpy.getCalls().map(function(call) { return call.args; })).to.deep.equal([ ['C'] ]); - }); - - it('Is default behavior when "ko.options.deferUpdates" is "true"', function() { - restoreAfter(ko.options, 'deferUpdates'); - ko.options.deferUpdates = true; - - var data = ko.observable('A'), - computed = ko.computed(data), - notifySpy = sinon.stub(), - subscription = computed.subscribe(notifySpy); - - // Notification is deferred - data('B'); - expect(notifySpy.called).to.equal(false); - - clock.tick(1); - expect(notifySpy.getCalls().map(function(call) { return call.args; })).to.deep.equal([ ['B'] ]); - }); - - it('Is superseded by rate-limit', function() { - restoreAfter(ko.options, 'deferUpdates'); - ko.options.deferUpdates = true; - - var data = ko.observable('A'), - deferredComputed = ko.computed(data), - dependentComputed = ko.computed(function() { return 'R' + deferredComputed(); }).extend({rateLimit: 500}), - notifySpy = sinon.stub(), - subscription1 = deferredComputed.subscribe(notifySpy), - subscription2 = dependentComputed.subscribe(notifySpy); - - expect(dependentComputed()).to.deep.equal('RA'); - - data('B'); - expect(deferredComputed()).to.deep.equal('B'); - expect(dependentComputed()).to.deep.equal('RB'); - expect(notifySpy.called).to.equal(false); // no notifications yet - - clock.tick(1); - expect(notifySpy.getCalls().map(function(call) { return call.args; })).to.deep.equal([ ['B'] ]); // only the deferred computed notifies initially - - clock.tick(499); - expect(notifySpy.getCalls().map(function(call) { return call.args; })).to.deep.equal([ ['B'], [ 'RB' ] ]); // the rate-limited computed notifies after the specified timeout - }); - - it('Should minimize evaluation at the end of a complex graph', function() { - restoreAfter(ko.options, 'deferUpdates'); - ko.options.deferUpdates = true; - - var a = ko.observable('a'), - b = ko.pureComputed(function b() { - return 'b' + a(); - }), - c = ko.pureComputed(function c() { - return 'c' + a(); - }), - d = ko.pureComputed(function d() { - return 'd(' + b() + ',' + c() + ')'; - }), - e = ko.pureComputed(function e() { - return 'e' + a(); - }), - f = ko.pureComputed(function f() { - return 'f' + a(); - }), - g = ko.pureComputed(function g() { - return 'g(' + e() + ',' + f() + ')'; - }), - h = ko.pureComputed(function h() { - return 'h(' + c() + ',' + g() + ',' + d() + ')'; - }), - i = ko.pureComputed(function i() { - return 'i(' + a() + ',' + h() + ',' + b() + ',' + f() + ')'; - }).extend({notify:"always"}), // ensure we get a notification for each evaluation - notifySpy = sinon.stub(), - subscription = i.subscribe(notifySpy); - - a('x'); - clock.tick(1); - expect(notifySpy.getCalls().map(function(call) { return call.args; })).to.deep.equal([['i(x,h(cx,g(ex,fx),d(bx,cx)),bx,fx)']]); // only one evaluation and notification - }); - - it('Should minimize evaluation when dependent computed doesn\'t actually change', function() { - // From https://github.com/knockout/knockout/issues/2174 - restoreAfter(ko.options, 'deferUpdates'); - ko.options.deferUpdates = true; - - var source = ko.observable({ key: 'value' }), - c1 = ko.computed(function () { - return source()['key']; - }), - countEval = 0, - c2 = ko.computed(function () { - countEval++; - return c1(); - }); - - source({ key: 'value' }); - clock.tick(1); - expect(countEval).to.deep.equal(1); - - // Reading it again shouldn't cause an update - expect(c2()).to.deep.equal(c1()); - expect(countEval).to.deep.equal(1); - }); - - it('Should ignore recursive dirty events', function() { - // From https://github.com/knockout/knockout/issues/1943 - restoreAfter(ko.options, 'deferUpdates'); - ko.options.deferUpdates = true; - - var a = ko.observable(), - b = ko.computed({ read : function() { a(); return d(); }, deferEvaluation : true }), - d = ko.computed({ read : function() { a(); return b(); }, deferEvaluation : true }), - bSpy = sinon.stub(), - dSpy = sinon.stub(); - - b.subscribe(bSpy, null, "dirty"); - d.subscribe(dSpy, null, "dirty"); - - d(); - expect(bSpy.called).to.equal(false); - expect(dSpy.called).to.equal(false); - - a('something'); - expect(bSpy.callCount).to.equal(2); // 1 for a, and 1 for d - expect(dSpy.callCount).to.equal(2); // 1 for a, and 1 for b - - clock.tick(1); - }); - - it('Should not cause loss of updates when an intermediate value is read by a dependent computed observable', function() { - // From https://github.com/knockout/knockout/issues/1835 - var one = ko.observable(false).extend({deferred: true}), - onePointOne = ko.computed(one).extend({deferred: true}), - two = ko.observable(false), - three = ko.computed(function() { return onePointOne() || two(); }), - threeNotifications = []; - - three.subscribe(function(val) { - threeNotifications.push(val); - }); - - // The loop shows that the same steps work continuously - for (var i = 0; i < 3; i++) { - expect(onePointOne() || two() || three()).to.deep.equal(false); - threeNotifications = []; - - one(true); - expect(threeNotifications).to.deep.equal([]); - two(true); - expect(threeNotifications).to.deep.equal([true]); - two(false); - expect(threeNotifications).to.deep.equal([true]); - one(false); - expect(threeNotifications).to.deep.equal([true]); - - clock.tick(1); - expect(threeNotifications).to.deep.equal([true, false]); - } - }); - - it('Should only notify changes if computed was evaluated', function() { - // See https://github.com/knockout/knockout/issues/2240 - // Set up a scenario where a computed will be marked as dirty but won't get marked as - // stale and so won't be re-evaluated - restoreAfter(ko.options, 'deferUpdates'); - ko.options.deferUpdates = true; - - var obs = ko.observable('somevalue'), - isTruthy = ko.pureComputed(function() { return !!obs(); }), - objIfTruthy = ko.pureComputed(function() { return isTruthy(); }).extend({ notify: 'always' }), - notifySpy = sinon.stub(), - subscription = objIfTruthy.subscribe(notifySpy); - - obs('someothervalue'); - clock.tick(1); - expect(notifySpy.called).to.equal(false); - - obs(''); - clock.tick(1); - expect(notifySpy.called).to.equal(true); - expect(notifySpy.getCalls().map(function(call) { return call.args; })).to.deep.equal([[false]]); - notifySpy.resetHistory(); - - obs(undefined); - clock.tick(1); - expect(notifySpy.called).to.equal(false); - }); - - it('Should not re-evaluate if pure computed becomes asleep while a notification is pending', function() { - restoreAfter(ko.options, 'deferUpdates'); - ko.options.deferUpdates = true; - - var data = ko.observable('A'), - timesEvaluated = 0, - computed1 = ko.computed(function () { - if (data() == 'B') - subscription.dispose(); - }), - computed2 = ko.pureComputed(function () { - timesEvaluated++; - return data() + '2'; - }), - notifySpy = sinon.stub(), - subscription = computed2.subscribe(notifySpy); - - // The computed is evaluated when awakened - expect(timesEvaluated).to.deep.equal(1); - - // When we update the observable, both computeds will be marked dirty and scheduled for notification - // But the first one will dispose the subscription to the second, putting it to sleep - data('B'); - clock.tick(1); - expect(timesEvaluated).to.deep.equal(1); - - // When we read the computed it should be evaluated again because its dependencies have changed - expect(computed2()).to.deep.equal('B2'); - expect(timesEvaluated).to.deep.equal(2); - - expect(notifySpy.called).to.equal(false); - }); - }); - - describe('ko.when', function() { - it('Runs callback in a sepearate task when predicate function becomes true, but only once', function() { - restoreAfter(ko.options, 'deferUpdates'); - ko.options.deferUpdates = true; - - var x = ko.observable(3), - called = 0; - - ko.when(function () { return x() === 4; }, function () { called++; }); - - x(5); - expect(called).to.equal(0); - expect(x.getSubscriptionsCount()).to.equal(1); - - x(4); - expect(called).to.equal(0); - - clock.tick(1); - expect(called).to.equal(1); - expect(x.getSubscriptionsCount()).to.equal(0); - - x(3); - x(4); - clock.tick(1); - expect(called).to.equal(1); - expect(x.getSubscriptionsCount()).to.equal(0); - }); - - it('Runs callback in a sepearate task if predicate function is already true', function() { - restoreAfter(ko.options, 'deferUpdates'); - ko.options.deferUpdates = true; - - var x = ko.observable(4), - called = 0; - - ko.when(function () { return x() === 4; }, function () { called++; }); - - expect(called).to.equal(0); - expect(x.getSubscriptionsCount()).to.equal(1); - - clock.tick(1); - expect(called).to.equal(1); - expect(x.getSubscriptionsCount()).to.equal(0); - - x(3); - x(4); - clock.tick(1); - expect(called).to.equal(1); - expect(x.getSubscriptionsCount()).to.equal(0); - }); - }); -}); + }) + .extend({ rateLimit: 500 }) + + // Initially the computed evaluated successfully + expect(computed()).to.deep.equal(1) + + expect(function () { + // Update observable to cause computed to throw an exception + observableSwitch(false) + computed() + }).to.throw('Error during computed evaluation') + + // The value of the computed is now undefined, although currently it keeps the previous value + // This should not try to re-evaluate and thus shouldn't throw an exception + expect(computed()).to.deep.equal(1) + // The computed should not be dependent on the second observable + expect(computed.getDependenciesCount()).to.deep.equal(1) + expect(computed.getDependencies()).to.deep.equal([observableSwitch]) + + // Updating the second observable shouldn't re-evaluate computed + observableValue(2) + expect(computed()).to.deep.equal(1) + + // Update the first observable to cause computed to re-evaluate + observableSwitch(1) + expect(computed()).to.deep.equal(2) + }) + + it('Should delay update of dependent computed observable', function () { + var observable = ko.observable() + var rateLimitComputed = ko.computed(observable).extend({ rateLimit: 500 }) + var dependentComputed = ko.computed(rateLimitComputed) + + // Check initial value + expect(dependentComputed()).to.equal(undefined) + + // Rate-limited computed is changed, but dependent computed is not + observable('a') + expect(rateLimitComputed()).to.deep.equal('a') + expect(dependentComputed()).to.equal(undefined) + + // Second change also + observable('b') + expect(dependentComputed()).to.equal(undefined) + + // Advance clock; Change notification happens now using the latest value notified + clock.tick(500) + expect(dependentComputed()).to.deep.equal('b') + }) + + it('Should delay update of dependent pure computed observable', function () { + var observable = ko.observable() + var rateLimitComputed = ko.computed(observable).extend({ rateLimit: 500 }) + var dependentComputed = ko.pureComputed(rateLimitComputed) + + // Check initial value + expect(dependentComputed()).to.equal(undefined) + + // Rate-limited computed is changed, but dependent computed is not + observable('a') + expect(rateLimitComputed()).to.deep.equal('a') + expect(dependentComputed()).to.equal(undefined) + + // Second change also + observable('b') + expect(dependentComputed()).to.equal(undefined) + + // Advance clock; Change notification happens now using the latest value notified + clock.tick(500) + expect(dependentComputed()).to.deep.equal('b') + }) + + it('Should not cause loss of updates when an intermediate value is read by a dependent computed observable', function () { + // From https://github.com/knockout/knockout/issues/1835 + var one = ko.observable(false), + onePointOne = ko.computed(one).extend({ rateLimit: 100 }), + two = ko.observable(false), + three = ko.computed(function () { + return onePointOne() || two() + }), + threeNotifications = [] + + three.subscribe(function (val) { + threeNotifications.push(val) + }) + + // The loop shows that the same steps work continuously + for (var i = 0; i < 3; i++) { + expect(onePointOne() || two() || three()).to.deep.equal(false) + threeNotifications = [] + + one(true) + expect(threeNotifications).to.deep.equal([]) + two(true) + expect(threeNotifications).to.deep.equal([true]) + two(false) + expect(threeNotifications).to.deep.equal([true]) + one(false) + expect(threeNotifications).to.deep.equal([true]) + + clock.tick(100) + expect(threeNotifications).to.deep.equal([true, false]) + } + }) + }) +}) + +describe('Deferred', function () { + beforeEach(function () { + clock = sinon.useFakeTimers() + restoreAfter(ko.options, 'taskScheduler') + restoreAfter(ko.tasks, 'scheduler') + ko.options.taskScheduler = function (callback) { + setTimeout(callback, 0) + } + ko.tasks.scheduler = function (callback) { + setTimeout(callback, 0) + } + }) + + afterEach(function () { + expect(ko.tasks.resetForTesting()).to.deep.equal(0) + clock.restore() + clock = null + }) + + describe('Observable', function () { + it('Should delay notifications', function () { + var observable = ko.observable().extend({ deferred: true }) + var notifySpy = sinon.stub() + observable.subscribe(notifySpy) + + observable('A') + expect(notifySpy.called).to.equal(false) + + clock.tick(1) + expect( + notifySpy.getCalls().map(function (call) { + return call.args + }) + ).to.deep.equal([['A']]) + }) + + it('Should throw if you attempt to turn off deferred', function () { + // As of commit 6d5d786, the 'deferred' option cannot be deactivated (once activated for + // a given observable). + var observable = ko.observable() + + observable.extend({ deferred: true }) + expect(function () { + observable.extend({ deferred: false }) + }).to.throw( + "The 'deferred' extender only accepts the value 'true', because it is not supported to turn deferral off once enabled." + ) + }) + + it('Should notify subscribers about only latest value', function () { + var observable = ko.observable().extend({ notify: 'always', deferred: true }) // include notify:'always' to ensure notifications weren't suppressed by some other means + var notifySpy = sinon.stub() + observable.subscribe(notifySpy) + + observable('A') + observable('B') + + clock.tick(1) + expect( + notifySpy.getCalls().map(function (call) { + return call.args + }) + ).to.deep.equal([['B']]) + }) + + it('Should suppress notification when value is changed/reverted', function () { + var observable = ko.observable('original').extend({ deferred: true }) + var notifySpy = sinon.stub() + observable.subscribe(notifySpy) + + observable('new') + expect(observable()).to.deep.equal('new') + observable('original') + + clock.tick(1) + expect(notifySpy.called).to.equal(false) + expect(observable()).to.deep.equal('original') + }) + + it('Should not notify future subscribers', function () { + var observable = ko.observable('a').extend({ deferred: true }), + notifySpy1 = sinon.stub(), + notifySpy2 = sinon.stub(), + notifySpy3 = sinon.stub() + + observable.subscribe(notifySpy1) + observable('b') + observable.subscribe(notifySpy2) + observable('c') + observable.subscribe(notifySpy3) + + expect(notifySpy1.called).to.equal(false) + expect(notifySpy2.called).to.equal(false) + expect(notifySpy3.called).to.equal(false) + + clock.tick(1) + expect(notifySpy1.calledWith('c')).to.equal(true) + expect(notifySpy2.calledWith('c')).to.equal(true) + expect(notifySpy3.called).to.equal(false) + }) + + it('Should not update dependent computed created after last update', function () { + var observable = ko.observable('a').extend({ deferred: true }) + observable('b') + + var evalSpy = sinon.stub() + var computed = ko.computed(function () { + return evalSpy(observable()) + }) + expect(evalSpy.calledWith('b')).to.equal(true) + evalSpy.resetHistory() + + clock.tick(1) + expect(evalSpy.called).to.equal(false) + }) + + it('Is default behavior when "ko.options.deferUpdates" is "true"', function () { + restoreAfter(ko.options, 'deferUpdates') + ko.options.deferUpdates = true + + var observable = ko.observable() + var notifySpy = sinon.stub() + observable.subscribe(notifySpy) + + observable('A') + expect(notifySpy.called).to.equal(false) + + clock.tick(1) + expect( + notifySpy.getCalls().map(function (call) { + return call.args + }) + ).to.deep.equal([['A']]) + }) + }) + + describe('Observable Array change tracking', function () { + it('Should provide correct changelist when multiple updates are merged into one notification', function () { + var myArray = ko.observableArray(['Alpha', 'Beta']).extend({ deferred: true }), + changelist + + myArray.subscribe( + function (changes) { + changelist = changes + }, + null, + 'arrayChange' + ) + + myArray.push('Gamma') + myArray.push('Delta') + clock.tick(1) + expect(changelist).to.deep.equal([ + { status: 'added', value: 'Gamma', index: 2 }, + { status: 'added', value: 'Delta', index: 3 } + ]) + + changelist = undefined + myArray.shift() + myArray.shift() + clock.tick(1) + expect(changelist).to.deep.equal([ + { status: 'deleted', value: 'Alpha', index: 0 }, + { status: 'deleted', value: 'Beta', index: 1 } + ]) + + changelist = undefined + myArray.push('Epsilon') + myArray.pop() + clock.tick(1) + expect(changelist).to.deep.equal(undefined) + }) + }) + + describe('Computed Observable', function () { + it('Should defer notification of changes and minimize evaluation', function () { + var timesEvaluated = 0, + data = ko.observable('A'), + computed = ko + .computed(function () { + ++timesEvaluated + return data() + }) + .extend({ deferred: true }), + notifySpy = sinon.stub(), + subscription = computed.subscribe(notifySpy) + + expect(computed()).to.deep.equal('A') + expect(timesEvaluated).to.deep.equal(1) + clock.tick(1) + expect(notifySpy.called).to.equal(false) + + data('B') + expect(timesEvaluated).to.deep.equal(1) // not immediately evaluated + expect(computed()).to.deep.equal('B') + expect(timesEvaluated).to.deep.equal(2) + expect(notifySpy.called).to.equal(false) + + clock.tick(1) + expect(notifySpy.callCount).to.deep.equal(1) + expect( + notifySpy.getCalls().map(function (call) { + return call.args + }) + ).to.deep.equal([['B']]) + }) + + it('Should notify first change of computed with deferEvaluation if value is changed to undefined', function () { + var data = ko.observable('A'), + computed = ko.computed(data, null, { deferEvaluation: true }).extend({ deferred: true }), + notifySpy = sinon.stub(), + subscription = computed.subscribe(notifySpy) + + expect(computed()).to.deep.equal('A') + + data(undefined) + expect(computed()).to.deep.equal(undefined) + expect(notifySpy.called).to.equal(false) + + clock.tick(1) + expect(notifySpy.callCount).to.deep.equal(1) + expect( + notifySpy.getCalls().map(function (call) { + return call.args + }) + ).to.deep.equal([[undefined]]) + }) + + it('Should notify first change to pure computed after awakening if value changed to last notified value', function () { + var data = ko.observable('A'), + computed = ko.pureComputed(data).extend({ deferred: true }), + notifySpy = sinon.stub(), + subscription = computed.subscribe(notifySpy) + + data('B') + expect(computed()).to.deep.equal('B') + expect(notifySpy.called).to.equal(false) + clock.tick(1) + expect( + notifySpy.getCalls().map(function (call) { + return call.args + }) + ).to.deep.equal([['B']]) + + subscription.dispose() + notifySpy.resetHistory() + data('C') + expect(computed()).to.deep.equal('C') + clock.tick(1) + expect(notifySpy.called).to.equal(false) + + subscription = computed.subscribe(notifySpy) + data('B') + expect(computed()).to.deep.equal('B') + expect(notifySpy.called).to.equal(false) + clock.tick(1) + expect( + notifySpy.getCalls().map(function (call) { + return call.args + }) + ).to.deep.equal([['B']]) + }) + + it('Should delay update of dependent computed observable', function () { + var data = ko.observable('A'), + deferredComputed = ko.computed(data).extend({ deferred: true }), + dependentComputed = ko.computed(deferredComputed) + + expect(dependentComputed()).to.deep.equal('A') + + data('B') + expect(deferredComputed()).to.deep.equal('B') + expect(dependentComputed()).to.deep.equal('A') + + data('C') + expect(dependentComputed()).to.deep.equal('A') + + clock.tick(1) + expect(dependentComputed()).to.deep.equal('C') + }) + + it('Should delay update of dependent pure computed observable', function () { + var data = ko.observable('A'), + deferredComputed = ko.computed(data).extend({ deferred: true }), + dependentComputed = ko.pureComputed(deferredComputed) + + expect(dependentComputed()).to.deep.equal('A') + + data('B') + expect(deferredComputed()).to.deep.equal('B') + expect(dependentComputed()).to.deep.equal('A') + + data('C') + expect(dependentComputed()).to.deep.equal('A') + + clock.tick(1) + expect(dependentComputed()).to.deep.equal('C') + }) + + it('Should *not* delay update of dependent deferred computed observable', function () { + var data = ko.observable('A').extend({ deferred: true }), + timesEvaluated = 0, + computed1 = ko + .computed(function () { + return data() + 'X' + }) + .extend({ deferred: true }), + computed2 = ko + .computed(function () { + timesEvaluated++ + return computed1() + 'Y' + }) + .extend({ deferred: true }), + notifySpy = sinon.stub(), + subscription = computed2.subscribe(notifySpy) + + expect(computed2()).to.deep.equal('AXY') + expect(timesEvaluated).to.deep.equal(1) + + data('B') + expect(computed2()).to.deep.equal('BXY') + expect(timesEvaluated).to.deep.equal(2) + expect(notifySpy.called).to.equal(false) + + clock.tick(1) + expect(computed2()).to.deep.equal('BXY') + expect(timesEvaluated).to.deep.equal(2) // Verify that the computed wasn't evaluated again unnecessarily + expect( + notifySpy.getCalls().map(function (call) { + return call.args + }) + ).to.deep.equal([['BXY']]) + }) + + it('Should *not* delay update of dependent deferred pure computed observable', function () { + var data = ko.observable('A').extend({ deferred: true }), + timesEvaluated = 0, + computed1 = ko + .pureComputed(function () { + return data() + 'X' + }) + .extend({ deferred: true }), + computed2 = ko + .pureComputed(function () { + timesEvaluated++ + return computed1() + 'Y' + }) + .extend({ deferred: true }) + + expect(computed2()).to.deep.equal('AXY') + expect(timesEvaluated).to.deep.equal(1) + + data('B') + expect(computed2()).to.deep.equal('BXY') + expect(timesEvaluated).to.deep.equal(2) + + clock.tick(1) + expect(computed2()).to.deep.equal('BXY') + expect(timesEvaluated).to.deep.equal(2) // Verify that the computed wasn't evaluated again unnecessarily + }) + + it('Should *not* delay update of dependent rate-limited computed observable', function () { + var data = ko.observable('A'), + deferredComputed = ko.computed(data).extend({ deferred: true }), + dependentComputed = ko.computed(deferredComputed).extend({ rateLimit: 500 }), + notifySpy = sinon.stub(), + subscription = dependentComputed.subscribe(notifySpy) + + expect(dependentComputed()).to.deep.equal('A') + + data('B') + expect(deferredComputed()).to.deep.equal('B') + expect(dependentComputed()).to.deep.equal('B') + + data('C') + expect(dependentComputed()).to.deep.equal('C') + expect(notifySpy.called).to.equal(false) + + clock.tick(500) + expect(dependentComputed()).to.deep.equal('C') + expect( + notifySpy.getCalls().map(function (call) { + return call.args + }) + ).to.deep.equal([['C']]) + }) + + it('Is default behavior when "ko.options.deferUpdates" is "true"', function () { + restoreAfter(ko.options, 'deferUpdates') + ko.options.deferUpdates = true + + var data = ko.observable('A'), + computed = ko.computed(data), + notifySpy = sinon.stub(), + subscription = computed.subscribe(notifySpy) + + // Notification is deferred + data('B') + expect(notifySpy.called).to.equal(false) + + clock.tick(1) + expect( + notifySpy.getCalls().map(function (call) { + return call.args + }) + ).to.deep.equal([['B']]) + }) + + it('Is superseded by rate-limit', function () { + restoreAfter(ko.options, 'deferUpdates') + ko.options.deferUpdates = true + + var data = ko.observable('A'), + deferredComputed = ko.computed(data), + dependentComputed = ko + .computed(function () { + return 'R' + deferredComputed() + }) + .extend({ rateLimit: 500 }), + notifySpy = sinon.stub(), + subscription1 = deferredComputed.subscribe(notifySpy), + subscription2 = dependentComputed.subscribe(notifySpy) + + expect(dependentComputed()).to.deep.equal('RA') + + data('B') + expect(deferredComputed()).to.deep.equal('B') + expect(dependentComputed()).to.deep.equal('RB') + expect(notifySpy.called).to.equal(false) // no notifications yet + + clock.tick(1) + expect( + notifySpy.getCalls().map(function (call) { + return call.args + }) + ).to.deep.equal([['B']]) // only the deferred computed notifies initially + + clock.tick(499) + expect( + notifySpy.getCalls().map(function (call) { + return call.args + }) + ).to.deep.equal([['B'], ['RB']]) // the rate-limited computed notifies after the specified timeout + }) + + it('Should minimize evaluation at the end of a complex graph', function () { + restoreAfter(ko.options, 'deferUpdates') + ko.options.deferUpdates = true + + var a = ko.observable('a'), + b = ko.pureComputed(function b() { + return 'b' + a() + }), + c = ko.pureComputed(function c() { + return 'c' + a() + }), + d = ko.pureComputed(function d() { + return 'd(' + b() + ',' + c() + ')' + }), + e = ko.pureComputed(function e() { + return 'e' + a() + }), + f = ko.pureComputed(function f() { + return 'f' + a() + }), + g = ko.pureComputed(function g() { + return 'g(' + e() + ',' + f() + ')' + }), + h = ko.pureComputed(function h() { + return 'h(' + c() + ',' + g() + ',' + d() + ')' + }), + i = ko + .pureComputed(function i() { + return 'i(' + a() + ',' + h() + ',' + b() + ',' + f() + ')' + }) + .extend({ notify: 'always' }), // ensure we get a notification for each evaluation + notifySpy = sinon.stub(), + subscription = i.subscribe(notifySpy) + + a('x') + clock.tick(1) + expect( + notifySpy.getCalls().map(function (call) { + return call.args + }) + ).to.deep.equal([['i(x,h(cx,g(ex,fx),d(bx,cx)),bx,fx)']]) // only one evaluation and notification + }) + + it("Should minimize evaluation when dependent computed doesn't actually change", function () { + // From https://github.com/knockout/knockout/issues/2174 + restoreAfter(ko.options, 'deferUpdates') + ko.options.deferUpdates = true + + var source = ko.observable({ key: 'value' }), + c1 = ko.computed(function () { + return source()['key'] + }), + countEval = 0, + c2 = ko.computed(function () { + countEval++ + return c1() + }) + + source({ key: 'value' }) + clock.tick(1) + expect(countEval).to.deep.equal(1) + + // Reading it again shouldn't cause an update + expect(c2()).to.deep.equal(c1()) + expect(countEval).to.deep.equal(1) + }) + + it('Should ignore recursive dirty events', function () { + // From https://github.com/knockout/knockout/issues/1943 + restoreAfter(ko.options, 'deferUpdates') + ko.options.deferUpdates = true + + var a = ko.observable(), + b = ko.computed({ + read: function () { + a() + return d() + }, + deferEvaluation: true + }), + d = ko.computed({ + read: function () { + a() + return b() + }, + deferEvaluation: true + }), + bSpy = sinon.stub(), + dSpy = sinon.stub() + + b.subscribe(bSpy, null, 'dirty') + d.subscribe(dSpy, null, 'dirty') + + d() + expect(bSpy.called).to.equal(false) + expect(dSpy.called).to.equal(false) + + a('something') + expect(bSpy.callCount).to.equal(2) // 1 for a, and 1 for d + expect(dSpy.callCount).to.equal(2) // 1 for a, and 1 for b + + clock.tick(1) + }) + + it('Should not cause loss of updates when an intermediate value is read by a dependent computed observable', function () { + // From https://github.com/knockout/knockout/issues/1835 + var one = ko.observable(false).extend({ deferred: true }), + onePointOne = ko.computed(one).extend({ deferred: true }), + two = ko.observable(false), + three = ko.computed(function () { + return onePointOne() || two() + }), + threeNotifications = [] + + three.subscribe(function (val) { + threeNotifications.push(val) + }) + + // The loop shows that the same steps work continuously + for (var i = 0; i < 3; i++) { + expect(onePointOne() || two() || three()).to.deep.equal(false) + threeNotifications = [] + + one(true) + expect(threeNotifications).to.deep.equal([]) + two(true) + expect(threeNotifications).to.deep.equal([true]) + two(false) + expect(threeNotifications).to.deep.equal([true]) + one(false) + expect(threeNotifications).to.deep.equal([true]) + + clock.tick(1) + expect(threeNotifications).to.deep.equal([true, false]) + } + }) + + it('Should only notify changes if computed was evaluated', function () { + // See https://github.com/knockout/knockout/issues/2240 + // Set up a scenario where a computed will be marked as dirty but won't get marked as + // stale and so won't be re-evaluated + restoreAfter(ko.options, 'deferUpdates') + ko.options.deferUpdates = true + + var obs = ko.observable('somevalue'), + isTruthy = ko.pureComputed(function () { + return !!obs() + }), + objIfTruthy = ko + .pureComputed(function () { + return isTruthy() + }) + .extend({ notify: 'always' }), + notifySpy = sinon.stub(), + subscription = objIfTruthy.subscribe(notifySpy) + + obs('someothervalue') + clock.tick(1) + expect(notifySpy.called).to.equal(false) + + obs('') + clock.tick(1) + expect(notifySpy.called).to.equal(true) + expect( + notifySpy.getCalls().map(function (call) { + return call.args + }) + ).to.deep.equal([[false]]) + notifySpy.resetHistory() + + obs(undefined) + clock.tick(1) + expect(notifySpy.called).to.equal(false) + }) + + it('Should not re-evaluate if pure computed becomes asleep while a notification is pending', function () { + restoreAfter(ko.options, 'deferUpdates') + ko.options.deferUpdates = true + + var data = ko.observable('A'), + timesEvaluated = 0, + computed1 = ko.computed(function () { + if (data() == 'B') subscription.dispose() + }), + computed2 = ko.pureComputed(function () { + timesEvaluated++ + return data() + '2' + }), + notifySpy = sinon.stub(), + subscription = computed2.subscribe(notifySpy) + + // The computed is evaluated when awakened + expect(timesEvaluated).to.deep.equal(1) + + // When we update the observable, both computeds will be marked dirty and scheduled for notification + // But the first one will dispose the subscription to the second, putting it to sleep + data('B') + clock.tick(1) + expect(timesEvaluated).to.deep.equal(1) + + // When we read the computed it should be evaluated again because its dependencies have changed + expect(computed2()).to.deep.equal('B2') + expect(timesEvaluated).to.deep.equal(2) + + expect(notifySpy.called).to.equal(false) + }) + }) + + describe('ko.when', function () { + it('Runs callback in a sepearate task when predicate function becomes true, but only once', function () { + restoreAfter(ko.options, 'deferUpdates') + ko.options.deferUpdates = true + + var x = ko.observable(3), + called = 0 + + ko.when( + function () { + return x() === 4 + }, + function () { + called++ + } + ) + + x(5) + expect(called).to.equal(0) + expect(x.getSubscriptionsCount()).to.equal(1) + + x(4) + expect(called).to.equal(0) + + clock.tick(1) + expect(called).to.equal(1) + expect(x.getSubscriptionsCount()).to.equal(0) + + x(3) + x(4) + clock.tick(1) + expect(called).to.equal(1) + expect(x.getSubscriptionsCount()).to.equal(0) + }) + + it('Runs callback in a sepearate task if predicate function is already true', function () { + restoreAfter(ko.options, 'deferUpdates') + ko.options.deferUpdates = true + + var x = ko.observable(4), + called = 0 + + ko.when( + function () { + return x() === 4 + }, + function () { + called++ + } + ) + + expect(called).to.equal(0) + expect(x.getSubscriptionsCount()).to.equal(1) + + clock.tick(1) + expect(called).to.equal(1) + expect(x.getSubscriptionsCount()).to.equal(0) + + x(3) + x(4) + clock.tick(1) + expect(called).to.equal(1) + expect(x.getSubscriptionsCount()).to.equal(0) + }) + }) +}) diff --git a/builds/knockout/spec/asyncBindingBehaviors.js b/builds/knockout/spec/asyncBindingBehaviors.js index 8205bad4..25e0f8a9 100644 --- a/builds/knockout/spec/asyncBindingBehaviors.js +++ b/builds/knockout/spec/asyncBindingBehaviors.js @@ -1,229 +1,287 @@ -describe("Deferred bindings", function() { - var clock, - originalTaskScheduler, - bindingSpy; - - beforeEach(function() { - prepareTestNode(); - clock = sinon.useFakeTimers(); - originalTaskScheduler = ko.options.taskScheduler; - ko.options.taskScheduler = function(callback) { - setTimeout(callback, 0); - }; - - ko.options.deferUpdates = true; - - bindingSpy = sinon.stub(); - ko.bindingHandlers.test = { - init: function (element, valueAccessor) { bindingSpy('init', ko.unwrap(valueAccessor())); }, - update: function (element, valueAccessor) { bindingSpy('update', ko.unwrap(valueAccessor())); } - }; - }); - afterEach(function() { - expect(ko.tasks.resetForTesting()).to.deep.equal(0); - ko.options.taskScheduler = originalTaskScheduler; - clock.restore(); - clock = null; - ko.options.deferUpdates = false; - bindingSpy = ko.bindingHandlers.test = null; - }); - - it("Should update bindings asynchronously", function() { - var observable = ko.observable('A'); - - // The initial "applyBindings" is synchronous - testNode.innerHTML = "
"; - ko.applyBindings({ myObservable: observable }, testNode); - expect(bindingSpy.getCalls().map(function(call) { return call.args; })).to.deep.equal([ ['init', 'A'], ['update', 'A'] ]); - - // When changing the observable, the update is deferred - bindingSpy.resetHistory(); - observable('B'); - expect(bindingSpy.called).to.equal(false); - - // Update is still deferred - observable('C'); - expect(bindingSpy.called).to.equal(false); - - clock.tick(1); - // Only the latest value is notified - expect(bindingSpy.getCalls().map(function(call) { return call.args; })).to.deep.equal([ ['update', 'C'] ]); - }); - - it("Should update templates asynchronously", function() { - var observable = ko.observable('A'); - - testNode.innerHTML = "
"; - ko.applyBindings({ myObservable: observable }, testNode); - expect(bindingSpy.getCalls().map(function(call) { return call.args; })).to.deep.equal([ ['init', 'A'], ['update', 'A'] ]); - - // mutate; template should not be updated yet - bindingSpy.resetHistory(); - observable('B'); - expect(bindingSpy.called).to.equal(false); - - // mutate again; template should not be updated yet - observable('C'); - expect(bindingSpy.called).to.equal(false); - - clock.tick(1); - // only the latest value should be used - expect(bindingSpy.getCalls().map(function(call) { return call.args; })).to.deep.equal([ ['init', 'C'], ['update', 'C'] ]); - }); - - it("Should update 'foreach' items asynchronously", function() { - var observable = ko.observableArray(["A"]); - - testNode.innerHTML = "
"; - ko.applyBindings({ myObservables: observable }, testNode); - expect(bindingSpy.getCalls().map(function(call) { return call.args; })).to.deep.equal([ ['init', 'A'], ['update', 'A'] ]); - - // mutate; template should not be updated yet - bindingSpy.resetHistory(); - observable(["A", "B"]); - expect(bindingSpy.called).to.equal(false); - - // mutate again; template should not be updated yet - observable(["A", "C"]); - expect(bindingSpy.called).to.equal(false); - - clock.tick(1); - // only the latest value should be used ("C" added but not "B") - expect(bindingSpy.getCalls().map(function(call) { return call.args; })).to.deep.equal([ ['init', 'C'], ['update', 'C'] ]); - - // When an element is deleted and then added in a new place, it should register as a move and - // not create new DOM elements or update any child bindings - bindingSpy.resetHistory(); - observable.remove("A"); - observable.push("A"); - - var nodeA = testNode.childNodes[0].childNodes[0], - nodeB = testNode.childNodes[0].childNodes[1]; - clock.tick(1); - expect(bindingSpy.called).to.equal(false); - expect(testNode.childNodes[0].childNodes[0]).to.equal(nodeB); - expect(testNode.childNodes[0].childNodes[1]).to.equal(nodeA); - }); - - it("Should be able to force an update using runEarly", function() { - // This is based on the logic used in https://github.com/rniemeyer/knockout-sortable that when an item - // is dragged and dropped in the same list, it must be deleted and re-added instead of being moved. - - testNode.innerHTML = "
"; - var someItems = ko.observableArray([ - { childProp: 'first child' }, - { childProp: 'second child' }, - { childProp: 'moving child' } - ]); - ko.applyBindings({ someItems: someItems }, testNode); - expectContainHtml(testNode.childNodes[0], 'first childsecond childmoving child'); - - var sourceIndex = 2, - targetIndex = 0, - itemNode = testNode.childNodes[0].childNodes[sourceIndex], - item = someItems()[sourceIndex]; - - // Simply removing and re-adding item isn't sufficient because it will be registered as a move and no new element will be added - // Using ko.tasks.runEarly between the updates ensures that the binding sees each individual update - someItems.splice(sourceIndex, 1); - ko.tasks.runEarly(); - someItems.splice(targetIndex, 0, item); - - clock.tick(1); - expectContainHtml(testNode.childNodes[0], 'moving childfirst childsecond child'); - expect(testNode.childNodes[0].childNodes[targetIndex]).not.to.equal(itemNode); // node was created anew so it's not the same - }); - - it('Should get latest value when conditionally included', function() { - // Test is based on example in https://github.com/knockout/knockout/issues/1975 - - testNode.innerHTML = "
"; - var value = ko.observable(0), - is1 = ko.pureComputed(function () { return value() == 1; }), - status = ko.pureComputed(function () { return is1() ? 'ok' : 'error'; }), - show = ko.pureComputed(function () { return value() > 0 && is1(); }); - - ko.applyBindings({ status: status, show: show }, testNode); - expectContainHtml(testNode.childNodes[0], ''); - - value(1); - clock.tick(1); - expectContainHtml(testNode.childNodes[0], '
ok
'); - - value(0); - clock.tick(1); - expectContainHtml(testNode.childNodes[0], ''); - - value(1); - clock.tick(1); - expectContainHtml(testNode.childNodes[0], '
ok
'); - }); - - it('Should update "if" binding before descendant bindings', function() { - // Based on example at https://github.com/knockout/knockout/pull/2226 - testNode.innerHTML = '
'; - var vm = { - street: ko.observable(), - streetNumber: ko.observable(), - hasAddress: ko.pureComputed(function () { return vm.streetNumber() && vm.street(); }) - }; - - ko.applyBindings(vm, testNode); - clock.tick(1); - expectContainText(testNode.childNodes[0], ''); - - vm.street('my street'); - vm.streetNumber('123'); - clock.tick(1); - expectContainText(testNode.childNodes[0], '123my street'); - - vm.street(null); - vm.streetNumber(null); - clock.tick(1); - expectContainText(testNode.childNodes[0], ''); - }); - - it('Should update "with" binding before descendant bindings', function() { - // Based on example at https://github.com/knockout/knockout/pull/2226 - testNode.innerHTML = '
'; - var vm = { - street: ko.observable(), - streetNumber: ko.observable(), - hasAddress: ko.pureComputed(function () { return vm.streetNumber() && vm.street(); }) - }; - - ko.applyBindings(vm, testNode); - clock.tick(1); - expectContainText(testNode.childNodes[0], ''); - - vm.street('my street'); - vm.streetNumber('123'); - clock.tick(1); - expectContainText(testNode.childNodes[0], '123my street'); - - vm.street(null); - vm.streetNumber(null); - clock.tick(1); - expectContainText(testNode.childNodes[0], ''); - }); - - it('Should leave descendant nodes unchanged if the value is truthy and remains truthy when changed', function() { - var someItem = ko.observable(true); - testNode.innerHTML = "
"; - var originalNode = testNode.childNodes[0].childNodes[0]; - - // Value is initially true, so nodes are retained - ko.applyBindings({ someItem: someItem, counter: 0 }, testNode); - expect(testNode.childNodes[0].childNodes[0].tagName.toLowerCase()).to.deep.equal("span"); - expect(testNode.childNodes[0].childNodes[0]).to.deep.equal(originalNode); - expectContainText(testNode, "1"); - - // Change the value to a different truthy value; see the previous SPAN remains - someItem('different truthy value'); - clock.tick(1); - expect(testNode.childNodes[0].childNodes[0].tagName.toLowerCase()).to.deep.equal("span"); - expect(testNode.childNodes[0].childNodes[0]).to.deep.equal(originalNode); - expectContainText(testNode, "1"); - }); - -}); +describe('Deferred bindings', function () { + var clock, originalTaskScheduler, bindingSpy + + beforeEach(function () { + prepareTestNode() + clock = sinon.useFakeTimers() + originalTaskScheduler = ko.options.taskScheduler + ko.options.taskScheduler = function (callback) { + setTimeout(callback, 0) + } + + ko.options.deferUpdates = true + + bindingSpy = sinon.stub() + ko.bindingHandlers.test = { + init: function (element, valueAccessor) { + bindingSpy('init', ko.unwrap(valueAccessor())) + }, + update: function (element, valueAccessor) { + bindingSpy('update', ko.unwrap(valueAccessor())) + } + } + }) + afterEach(function () { + expect(ko.tasks.resetForTesting()).to.deep.equal(0) + ko.options.taskScheduler = originalTaskScheduler + clock.restore() + clock = null + ko.options.deferUpdates = false + bindingSpy = ko.bindingHandlers.test = null + }) + + it('Should update bindings asynchronously', function () { + var observable = ko.observable('A') + + // The initial "applyBindings" is synchronous + testNode.innerHTML = "
" + ko.applyBindings({ myObservable: observable }, testNode) + expect( + bindingSpy.getCalls().map(function (call) { + return call.args + }) + ).to.deep.equal([ + ['init', 'A'], + ['update', 'A'] + ]) + + // When changing the observable, the update is deferred + bindingSpy.resetHistory() + observable('B') + expect(bindingSpy.called).to.equal(false) + + // Update is still deferred + observable('C') + expect(bindingSpy.called).to.equal(false) + + clock.tick(1) + // Only the latest value is notified + expect( + bindingSpy.getCalls().map(function (call) { + return call.args + }) + ).to.deep.equal([['update', 'C']]) + }) + + it('Should update templates asynchronously', function () { + var observable = ko.observable('A') + + testNode.innerHTML = "
" + ko.applyBindings({ myObservable: observable }, testNode) + expect( + bindingSpy.getCalls().map(function (call) { + return call.args + }) + ).to.deep.equal([ + ['init', 'A'], + ['update', 'A'] + ]) + + // mutate; template should not be updated yet + bindingSpy.resetHistory() + observable('B') + expect(bindingSpy.called).to.equal(false) + + // mutate again; template should not be updated yet + observable('C') + expect(bindingSpy.called).to.equal(false) + + clock.tick(1) + // only the latest value should be used + expect( + bindingSpy.getCalls().map(function (call) { + return call.args + }) + ).to.deep.equal([ + ['init', 'C'], + ['update', 'C'] + ]) + }) + + it("Should update 'foreach' items asynchronously", function () { + var observable = ko.observableArray(['A']) + + testNode.innerHTML = "
" + ko.applyBindings({ myObservables: observable }, testNode) + expect( + bindingSpy.getCalls().map(function (call) { + return call.args + }) + ).to.deep.equal([ + ['init', 'A'], + ['update', 'A'] + ]) + + // mutate; template should not be updated yet + bindingSpy.resetHistory() + observable(['A', 'B']) + expect(bindingSpy.called).to.equal(false) + + // mutate again; template should not be updated yet + observable(['A', 'C']) + expect(bindingSpy.called).to.equal(false) + + clock.tick(1) + // only the latest value should be used ("C" added but not "B") + expect( + bindingSpy.getCalls().map(function (call) { + return call.args + }) + ).to.deep.equal([ + ['init', 'C'], + ['update', 'C'] + ]) + + // When an element is deleted and then added in a new place, it should register as a move and + // not create new DOM elements or update any child bindings + bindingSpy.resetHistory() + observable.remove('A') + observable.push('A') + + var nodeA = testNode.childNodes[0].childNodes[0], + nodeB = testNode.childNodes[0].childNodes[1] + clock.tick(1) + expect(bindingSpy.called).to.equal(false) + expect(testNode.childNodes[0].childNodes[0]).to.equal(nodeB) + expect(testNode.childNodes[0].childNodes[1]).to.equal(nodeA) + }) + + it('Should be able to force an update using runEarly', function () { + // This is based on the logic used in https://github.com/rniemeyer/knockout-sortable that when an item + // is dragged and dropped in the same list, it must be deleted and re-added instead of being moved. + + testNode.innerHTML = "
" + var someItems = ko.observableArray([ + { childProp: 'first child' }, + { childProp: 'second child' }, + { childProp: 'moving child' } + ]) + ko.applyBindings({ someItems: someItems }, testNode) + expectContainHtml( + testNode.childNodes[0], + 'first childsecond childmoving child' + ) + + var sourceIndex = 2, + targetIndex = 0, + itemNode = testNode.childNodes[0].childNodes[sourceIndex], + item = someItems()[sourceIndex] + + // Simply removing and re-adding item isn't sufficient because it will be registered as a move and no new element will be added + // Using ko.tasks.runEarly between the updates ensures that the binding sees each individual update + someItems.splice(sourceIndex, 1) + ko.tasks.runEarly() + someItems.splice(targetIndex, 0, item) + + clock.tick(1) + expectContainHtml( + testNode.childNodes[0], + 'moving childfirst childsecond child' + ) + expect(testNode.childNodes[0].childNodes[targetIndex]).not.to.equal(itemNode) // node was created anew so it's not the same + }) + + it('Should get latest value when conditionally included', function () { + // Test is based on example in https://github.com/knockout/knockout/issues/1975 + + testNode.innerHTML = '
' + var value = ko.observable(0), + is1 = ko.pureComputed(function () { + return value() == 1 + }), + status = ko.pureComputed(function () { + return is1() ? 'ok' : 'error' + }), + show = ko.pureComputed(function () { + return value() > 0 && is1() + }) + + ko.applyBindings({ status: status, show: show }, testNode) + expectContainHtml(testNode.childNodes[0], '') + + value(1) + clock.tick(1) + expectContainHtml(testNode.childNodes[0], '
ok
') + + value(0) + clock.tick(1) + expectContainHtml(testNode.childNodes[0], '') + + value(1) + clock.tick(1) + expectContainHtml(testNode.childNodes[0], '
ok
') + }) + + it('Should update "if" binding before descendant bindings', function () { + // Based on example at https://github.com/knockout/knockout/pull/2226 + testNode.innerHTML = + '
' + var vm = { + street: ko.observable(), + streetNumber: ko.observable(), + hasAddress: ko.pureComputed(function () { + return vm.streetNumber() && vm.street() + }) + } + + ko.applyBindings(vm, testNode) + clock.tick(1) + expectContainText(testNode.childNodes[0], '') + + vm.street('my street') + vm.streetNumber('123') + clock.tick(1) + expectContainText(testNode.childNodes[0], '123my street') + + vm.street(null) + vm.streetNumber(null) + clock.tick(1) + expectContainText(testNode.childNodes[0], '') + }) + + it('Should update "with" binding before descendant bindings', function () { + // Based on example at https://github.com/knockout/knockout/pull/2226 + testNode.innerHTML = + '
' + var vm = { + street: ko.observable(), + streetNumber: ko.observable(), + hasAddress: ko.pureComputed(function () { + return vm.streetNumber() && vm.street() + }) + } + + ko.applyBindings(vm, testNode) + clock.tick(1) + expectContainText(testNode.childNodes[0], '') + + vm.street('my street') + vm.streetNumber('123') + clock.tick(1) + expectContainText(testNode.childNodes[0], '123my street') + + vm.street(null) + vm.streetNumber(null) + clock.tick(1) + expectContainText(testNode.childNodes[0], '') + }) + + it('Should leave descendant nodes unchanged if the value is truthy and remains truthy when changed', function () { + var someItem = ko.observable(true) + testNode.innerHTML = "
" + var originalNode = testNode.childNodes[0].childNodes[0] + + // Value is initially true, so nodes are retained + ko.applyBindings({ someItem: someItem, counter: 0 }, testNode) + expect(testNode.childNodes[0].childNodes[0].tagName.toLowerCase()).to.deep.equal('span') + expect(testNode.childNodes[0].childNodes[0]).to.deep.equal(originalNode) + expectContainText(testNode, '1') + + // Change the value to a different truthy value; see the previous SPAN remains + someItem('different truthy value') + clock.tick(1) + expect(testNode.childNodes[0].childNodes[0].tagName.toLowerCase()).to.deep.equal('span') + expect(testNode.childNodes[0].childNodes[0]).to.deep.equal(originalNode) + expectContainText(testNode, '1') + }) +}) diff --git a/builds/knockout/spec/bindingAttributeBehaviors.js b/builds/knockout/spec/bindingAttributeBehaviors.js index e837d875..d932df04 100644 --- a/builds/knockout/spec/bindingAttributeBehaviors.js +++ b/builds/knockout/spec/bindingAttributeBehaviors.js @@ -1,746 +1,811 @@ -describe('Binding attribute syntax', function() { - beforeEach(prepareTestNode); - - it('applyBindings should accept no parameters and then act on document.body with undefined model', function() { - after(function () { ko.cleanNode(document.body); }); // Just to avoid interfering with other specs - - var didInit = false; - ko.bindingHandlers.test = { - init: function (element, valueAccessor, allBindings, viewModel) { - expect(element.id).to.deep.equal("testElement"); - expect(viewModel).to.equal(undefined); - didInit = true; - } - }; - testNode.innerHTML = "
"; - ko.applyBindings(); - expect(didInit).to.deep.equal(true); - }); - - it('applyBindings should accept one parameter and then act on document.body with parameter as model', function() { - after(function () { ko.cleanNode(document.body); }); // Just to avoid interfering with other specs - - var didInit = false; - var suppliedViewModel = {}; - ko.bindingHandlers.test = { - init: function (element, valueAccessor, allBindings, viewModel) { - expect(element.id).to.deep.equal("testElement"); - expect(viewModel).to.deep.equal(suppliedViewModel); - didInit = true; - } - }; - testNode.innerHTML = "
"; - ko.applyBindings(suppliedViewModel); - expect(didInit).to.deep.equal(true); - }); - - it('applyBindings should accept two parameters and then act on second param as DOM node with first param as model', function() { - var didInit = false; - var suppliedViewModel = {}; - ko.bindingHandlers.test = { - init: function (element, valueAccessor, allBindings, viewModel) { - expect(element.id).to.deep.equal("testElement"); - expect(viewModel).to.deep.equal(suppliedViewModel); - didInit = true; - } - }; - testNode.innerHTML = "
"; - - var shouldNotMatchNode = document.createElement("DIV"); - shouldNotMatchNode.innerHTML = "
"; - document.body.appendChild(shouldNotMatchNode); - after(function () { document.body.removeChild(shouldNotMatchNode); }); - - ko.applyBindings(suppliedViewModel, testNode); - expect(didInit).to.deep.equal(true); - }); - - it('applyBindings should accept three parameters and use the third parameter as a callback for modifying the root context', function() { - var didInit = false; - ko.bindingHandlers.test = { - init: function (element, valueAccessor, allBindings, viewModel, bindingContext) { - expect(bindingContext.extraValue).to.deep.equal("extra"); - didInit = true; - } - }; - testNode.innerHTML = "
"; - ko.applyBindings(null, testNode, function(context) { - context.extraValue = "extra"; - }); - expect(didInit).to.deep.equal(true); - }); - - it('Should tolerate empty or only white-space binding strings', function() { - testNode.innerHTML = "
"; - ko.applyBindings(null, testNode); // No exception means success - }); - - it('Should tolerate whitespace and nonexistent handlers', function () { - testNode.innerHTML = "
"; - ko.applyBindings(null, testNode); // No exception means success - }); - - it('Should tolerate arbitrary literals as the values for a handler', function () { - testNode.innerHTML = "
"; - ko.applyBindings(null, testNode); // No exception means success - }); - - it('Should tolerate wacky IE conditional comments', function() { - // Represents issue https://github.com/SteveSanderson/knockout/issues/186. Would fail on IE9, but work on earlier IE versions. - testNode.innerHTML = "
Hello
"; - ko.applyBindings(null, testNode); // No exception means success - }); - - it('Should produce a meaningful error if a binding value contains invalid JavaScript', function() { - ko.bindingHandlers.test = { - init: function (element, valueAccessor) { valueAccessor(); } - }; - testNode.innerHTML = "
"; - expect(function () { - ko.applyBindings(null, testNode); - }).to.throw("Bad operator"); - }); - - it('Should produce a meaningful error if a binding value doesn\'t exist', function() { - ko.bindingHandlers.test = { - init: function (element, valueAccessor) { valueAccessor(); } - }; - testNode.innerHTML = "
"; - expect(function () { - ko.applyBindings(null, testNode); - }).to.throw("Unable to process binding \"test\""); - }); - - it('Should invoke registered handlers\'s init() then update() methods passing binding data', function () { - var methodsInvoked = []; - ko.bindingHandlers.test = { - init: function (element, valueAccessor, allBindings) { - methodsInvoked.push("init"); - expect(element.id).to.deep.equal("testElement"); - expect(valueAccessor()).to.deep.equal("Hello"); - expect(allBindings.get('another')).to.deep.equal(123); - }, - update: function (element, valueAccessor, allBindings) { - methodsInvoked.push("update"); - expect(element.id).to.deep.equal("testElement"); - expect(valueAccessor()).to.deep.equal("Hello"); - expect(allBindings.get('another')).to.deep.equal(123); - } +describe('Binding attribute syntax', function () { + beforeEach(prepareTestNode) + + it('applyBindings should accept no parameters and then act on document.body with undefined model', function () { + after(function () { + ko.cleanNode(document.body) + }) // Just to avoid interfering with other specs + + var didInit = false + ko.bindingHandlers.test = { + init: function (element, valueAccessor, allBindings, viewModel) { + expect(element.id).to.deep.equal('testElement') + expect(viewModel).to.equal(undefined) + didInit = true + } + } + testNode.innerHTML = "
" + ko.applyBindings() + expect(didInit).to.deep.equal(true) + }) + + it('applyBindings should accept one parameter and then act on document.body with parameter as model', function () { + after(function () { + ko.cleanNode(document.body) + }) // Just to avoid interfering with other specs + + var didInit = false + var suppliedViewModel = {} + ko.bindingHandlers.test = { + init: function (element, valueAccessor, allBindings, viewModel) { + expect(element.id).to.deep.equal('testElement') + expect(viewModel).to.deep.equal(suppliedViewModel) + didInit = true + } + } + testNode.innerHTML = "
" + ko.applyBindings(suppliedViewModel) + expect(didInit).to.deep.equal(true) + }) + + it('applyBindings should accept two parameters and then act on second param as DOM node with first param as model', function () { + var didInit = false + var suppliedViewModel = {} + ko.bindingHandlers.test = { + init: function (element, valueAccessor, allBindings, viewModel) { + expect(element.id).to.deep.equal('testElement') + expect(viewModel).to.deep.equal(suppliedViewModel) + didInit = true + } + } + testNode.innerHTML = "
" + + var shouldNotMatchNode = document.createElement('DIV') + shouldNotMatchNode.innerHTML = "
" + document.body.appendChild(shouldNotMatchNode) + after(function () { + document.body.removeChild(shouldNotMatchNode) + }) + + ko.applyBindings(suppliedViewModel, testNode) + expect(didInit).to.deep.equal(true) + }) + + it('applyBindings should accept three parameters and use the third parameter as a callback for modifying the root context', function () { + var didInit = false + ko.bindingHandlers.test = { + init: function (element, valueAccessor, allBindings, viewModel, bindingContext) { + expect(bindingContext.extraValue).to.deep.equal('extra') + didInit = true + } + } + testNode.innerHTML = "
" + ko.applyBindings(null, testNode, function (context) { + context.extraValue = 'extra' + }) + expect(didInit).to.deep.equal(true) + }) + + it('Should tolerate empty or only white-space binding strings', function () { + testNode.innerHTML = "
" + ko.applyBindings(null, testNode) // No exception means success + }) + + it('Should tolerate whitespace and nonexistent handlers', function () { + testNode.innerHTML = '
' + ko.applyBindings(null, testNode) // No exception means success + }) + + it('Should tolerate arbitrary literals as the values for a handler', function () { + testNode.innerHTML = + '
' + ko.applyBindings(null, testNode) // No exception means success + }) + + it('Should tolerate wacky IE conditional comments', function () { + // Represents issue https://github.com/SteveSanderson/knockout/issues/186. Would fail on IE9, but work on earlier IE versions. + testNode.innerHTML = '
Hello
' + ko.applyBindings(null, testNode) // No exception means success + }) + + it('Should produce a meaningful error if a binding value contains invalid JavaScript', function () { + ko.bindingHandlers.test = { + init: function (element, valueAccessor) { + valueAccessor() + } + } + testNode.innerHTML = "
" + expect(function () { + ko.applyBindings(null, testNode) + }).to.throw('Bad operator') + }) + + it("Should produce a meaningful error if a binding value doesn't exist", function () { + ko.bindingHandlers.test = { + init: function (element, valueAccessor) { + valueAccessor() + } + } + testNode.innerHTML = "
" + expect(function () { + ko.applyBindings(null, testNode) + }).to.throw('Unable to process binding "test"') + }) + + it("Should invoke registered handlers's init() then update() methods passing binding data", function () { + var methodsInvoked = [] + ko.bindingHandlers.test = { + init: function (element, valueAccessor, allBindings) { + methodsInvoked.push('init') + expect(element.id).to.deep.equal('testElement') + expect(valueAccessor()).to.deep.equal('Hello') + expect(allBindings.get('another')).to.deep.equal(123) + }, + update: function (element, valueAccessor, allBindings) { + methodsInvoked.push('update') + expect(element.id).to.deep.equal('testElement') + expect(valueAccessor()).to.deep.equal('Hello') + expect(allBindings.get('another')).to.deep.equal(123) + } + } + testNode.innerHTML = "
" + ko.applyBindings(null, testNode) + expect(methodsInvoked.length).to.deep.equal(2) + expect(methodsInvoked[0]).to.deep.equal('init') + expect(methodsInvoked[1]).to.deep.equal('update') + }) + + it("Should invoke each handlers's init() and update() before running the next one", function () { + var methodsInvoked = [] + ko.bindingHandlers.test1 = ko.bindingHandlers.test2 = { + init: function (element, valueAccessor) { + methodsInvoked.push('init' + valueAccessor()) + }, + update: function (element, valueAccessor) { + methodsInvoked.push('update' + valueAccessor()) + } + } + testNode.innerHTML = '
' + ko.applyBindings(null, testNode) + expect(methodsInvoked).to.deep.equal(['init1', 'update1', 'init2', 'update2']) + }) + + it('Should be able to use $element in binding value', function () { + testNode.innerHTML = "
" + ko.applyBindings({}, testNode) + expectContainText(testNode, 'DIV') + }) + + it('Should be able to use $context in binding value to refer to the context object', function () { + testNode.innerHTML = "
" + ko.applyBindings({}, testNode) + expectContainText(testNode, 'true') + }) + + it('Should be able to refer to the bound object itself (at the root scope, the viewmodel) via $data', function () { + testNode.innerHTML = "
" + ko.applyBindings({ someProp: 'My prop value' }, testNode) + expectContainText(testNode, 'My prop value') + }) + + it('Bindings can signal that they control descendant bindings by returning a flag from their init function', function () { + ko.bindingHandlers.test = { + init: function () { + return { controlsDescendantBindings: true } + } + } + testNode.innerHTML = + "
" + + "
456
" + + '
' + + "
456
" + ko.applyBindings(null, testNode) + + expect(testNode.childNodes[0].childNodes[0].innerHTML).to.deep.equal('456') + expect(testNode.childNodes[1].innerHTML).to.deep.equal('123') + }) + + it('Should not be allowed to have multiple bindings on the same element that claim to control descendant bindings', function () { + ko.bindingHandlers.test1 = { + init: function () { + return { controlsDescendantBindings: true } + } + } + ko.bindingHandlers.test2 = ko.bindingHandlers.test1 + testNode.innerHTML = "
" + expect(function () { + ko.applyBindings(null, testNode) + }).to.throw('Multiple bindings (test1 and test2) are trying to control descendant bindings of the same element.') + }) + + it('Should use properties on the view model in preference to properties on the binding context', function () { + testNode.innerHTML = "
" + var outerNode = document.createElement('div') + document.body.appendChild(outerNode) + after(function () { + document.body.removeChild(outerNode) + }) + ko.applyBindings({ someProp: 'Outer value' }, outerNode) + var outerContext = ko.contextFor(outerNode) + ko.cleanNode(outerNode) + ko.applyBindings(outerContext.createChildContext({ someProp: 'Inner value' }), testNode) + expectContainText(testNode, 'Inner value') + }) + + it('Should be able to extend a binding context, adding new custom properties, without mutating the original binding context', function () { + ko.bindingHandlers.addCustomProperty = { + init: function (element, valueAccessor, allBindings, viewModel, bindingContext) { + ko.applyBindingsToDescendants(bindingContext.extend({ $customProp: 'my value' }), element) + return { controlsDescendantBindings: true } + } + } + testNode.innerHTML = + "
" + var vm = { sub: {} } + ko.applyBindings(vm, testNode) + expectContainText(testNode, 'my value') + expect(ko.contextFor(testNode.childNodes[0].childNodes[0].childNodes[0]).$customProp).to.deep.equal('my value') + expect(ko.contextFor(testNode.childNodes[0].childNodes[0]).$customProp).to.equal(undefined) // Should not affect original binding context + + // vale of $data and $parent should be unchanged in extended context + expect(ko.contextFor(testNode.childNodes[0].childNodes[0].childNodes[0]).$data).to.deep.equal(vm.sub) + expect(ko.contextFor(testNode.childNodes[0].childNodes[0].childNodes[0]).$parent).to.deep.equal(vm) + }) + + it('Binding contexts should inherit any custom properties from ancestor binding contexts', function () { + ko.bindingHandlers.addCustomProperty = { + init: function (element, valueAccessor, allBindings, viewModel, bindingContext) { + ko.applyBindingsToDescendants(bindingContext.extend({ $customProp: 'my value' }), element) + return { controlsDescendantBindings: true } + } + } + testNode.innerHTML = + "
" + ko.applyBindings(null, testNode) + expectContainText(testNode, 'my value') + }) + + it('Binding context should hide or not minify extra internal properties', function () { + testNode.innerHTML = "
" + ko.applyBindings({}, testNode) + + var allowedProperties = ['$parents', '$root', 'ko', '$rawData', '$data', '$parentContext', '$parent'] + if (ko.utils.createSymbolOrString('') === '') { + allowedProperties.push('_subscribable') + allowedProperties.push('_ancestorBindingInfo') + } + ko.utils.objectForEach(ko.contextFor(testNode.childNodes[0].childNodes[0]), function (prop) { + expect(allowedProperties).to.contain(prop) + }) + }) + + it('Should be able to retrieve the binding context associated with any node', function () { + testNode.innerHTML = "
" + ko.applyBindings({ name: 'Bert' }, testNode.childNodes[0]) + + expectContainText(testNode.childNodes[0].childNodes[0], 'Bert') + + // Can't get binding context for unbound nodes + expect(ko.dataFor(testNode)).to.equal(undefined) + expect(ko.contextFor(testNode)).to.equal(undefined) + + // Can get binding context for directly bound nodes + expect(ko.dataFor(testNode.childNodes[0]).name).to.deep.equal('Bert') + expect(ko.contextFor(testNode.childNodes[0]).$data.name).to.deep.equal('Bert') + + // Can get binding context for descendants of directly bound nodes + expect(ko.dataFor(testNode.childNodes[0].childNodes[0]).name).to.deep.equal('Bert') + expect(ko.contextFor(testNode.childNodes[0].childNodes[0]).$data.name).to.deep.equal('Bert') + + // Also test that a non-node object returns nothing and doesn't crash + expect(ko.dataFor({})).to.equal(undefined) + expect(ko.contextFor({})).to.equal(undefined) + }) + + it('Should not return a context object for unbound elements that are descendants of bound elements', function () { + // From https://github.com/knockout/knockout/issues/2148 + testNode.innerHTML = + '
Some text
' + + ko.bindingHandlers.allowBindings = { + init: function (elem, valueAccessor) { + // Let bindings proceed as normal *only if* my value is false + var shouldAllowBindings = ko.unwrap(valueAccessor()) + return { controlsDescendantBindings: !shouldAllowBindings } + } + } + var vm = { isVisible: true } + ko.applyBindings(vm, testNode) + + // All of the bound nodes return the viewmodel + expect(ko.dataFor(testNode.childNodes[0])).to.equal(vm) + expect(ko.dataFor(testNode.childNodes[0].childNodes[0])).to.equal(vm) + expect(ko.dataFor(testNode.childNodes[0].childNodes[1])).to.equal(vm) + expect(ko.contextFor(testNode.childNodes[0].childNodes[1]).$data).to.equal(vm) + + // The unbound child node returns undefined + expect(ko.dataFor(testNode.childNodes[0].childNodes[1].childNodes[0])).to.equal(undefined) + expect(ko.contextFor(testNode.childNodes[0].childNodes[1].childNodes[0])).to.equal(undefined) + }) + + it('Should return the context object for nodes specifically bound, but override with general binding', function () { + // See https://github.com/knockout/knockout/issues/231#issuecomment-388210267 + testNode.innerHTML = '
' + + var vm1 = { name: 'specific' } + ko.applyBindingsToNode(testNode.childNodes[0], { text: vm1.name }, vm1) + expectContainText(testNode, vm1.name) + expect(ko.dataFor(testNode.childNodes[0])).to.equal(vm1) + expect(ko.contextFor(testNode.childNodes[0]).$data).to.equal(vm1) + + var vm2 = { name: 'general' } + ko.applyBindings(vm2, testNode) + expectContainText(testNode, vm2.name) + expect(ko.dataFor(testNode.childNodes[0])).to.equal(vm2) + expect(ko.contextFor(testNode.childNodes[0]).$data).to.equal(vm2) + }) + + it('Should not be allowed to use containerless binding syntax for bindings other than whitelisted ones', function () { + testNode.innerHTML = 'Hello Some text Goodbye' + expect(function () { + ko.applyBindings(null, testNode) + }).to.throw("The binding 'visible' cannot be used with virtual elements") + }) + + it('Should be able to set a custom binding to use containerless binding', function () { + var initCalls = 0 + ko.bindingHandlers.test = { + init: function () { + initCalls++ + } + } + ko.virtualElements.allowedBindings['test'] = true + + testNode.innerHTML = 'Hello Some text Goodbye' + ko.applyBindings(null, testNode) + + expect(initCalls).to.deep.equal(1) + expectContainText(testNode, 'Hello Some text Goodbye') + }) + + it('Should be allowed to express containerless bindings with arbitrary internal whitespace and newlines', function () { + testNode.innerHTML = + 'Hello , Goodbye' + ko.applyBindings(null, testNode) + expectContainText(testNode, 'Hello Bert, Goodbye') + }) + + it('Should reject closing virtual bindings without matching open, when found as a sibling', function () { + testNode.innerHTML = 'x
x' + expect(function () { + ko.applyBindings(null, testNode) + }).to.throw() + }) + + it('Should reject closing virtual bindings without matching open, when found as a a first child', function () { + testNode.innerHTML = '
xx
' + expect(function () { + ko.applyBindings(null, testNode) + }).to.throw() + }) + + it('Should reject closing virtual bindings, when found as first child at the top level', function () { + testNode.innerHTML = 'xx' + expect(function () { + ko.applyBindings(null, testNode) + }).to.throw() + }) + + it('Should reject duplicated closing virtual bindings', function () { + testNode.innerHTML = 'x
x' + expect(function () { + ko.applyBindings(null, testNode) + }).to.throw() + }) + + it('Should reject opening virtual bindings that are not closed', function () { + testNode.innerHTML = 'xx' + expect(function () { + ko.applyBindings(null, testNode) + }).to.throw() + }) + + it('Should reject virtual bindings that are nested incorrectly', function () { + testNode.innerHTML = 'x
x' + expect(function () { + ko.applyBindings(null, testNode) + }).to.throw() + }) + + it('Should be able to access virtual children in custom containerless binding', function () { + var countNodes = 0 + ko.bindingHandlers.test = { + init: function (element, valueAccessor) { + // Counts the number of virtual children, and overwrites the text contents of any text nodes + for (var node = ko.virtualElements.firstChild(element); node; node = ko.virtualElements.nextSibling(node)) { + countNodes++ + if (node.nodeType === Node.TEXT_NODE) node.data = 'new text' } - testNode.innerHTML = "
"; - ko.applyBindings(null, testNode); - expect(methodsInvoked.length).to.deep.equal(2); - expect(methodsInvoked[0]).to.deep.equal("init"); - expect(methodsInvoked[1]).to.deep.equal("update"); - }); - - it('Should invoke each handlers\'s init() and update() before running the next one', function () { - var methodsInvoked = []; - ko.bindingHandlers.test1 = ko.bindingHandlers.test2 = { - init: function (element, valueAccessor) { - methodsInvoked.push("init" + valueAccessor()); - }, - update: function (element, valueAccessor) { - methodsInvoked.push("update" + valueAccessor()); - } - }; - testNode.innerHTML = "
"; - ko.applyBindings(null, testNode); - expect(methodsInvoked).to.deep.equal(['init1', 'update1', 'init2', 'update2']); - }); - - it('Should be able to use $element in binding value', function() { - testNode.innerHTML = "
"; - ko.applyBindings({}, testNode); - expectContainText(testNode, "DIV"); - }); - - it('Should be able to use $context in binding value to refer to the context object', function() { - testNode.innerHTML = "
"; - ko.applyBindings({}, testNode); - expectContainText(testNode, "true"); - }); - - it('Should be able to refer to the bound object itself (at the root scope, the viewmodel) via $data', function() { - testNode.innerHTML = "
"; - ko.applyBindings({ someProp: 'My prop value' }, testNode); - expectContainText(testNode, "My prop value"); - }); - - it('Bindings can signal that they control descendant bindings by returning a flag from their init function', function() { - ko.bindingHandlers.test = { - init: function() { return { controlsDescendantBindings : true } } - }; - testNode.innerHTML = "
" - + "
456
" - + "
" - + "
456
"; - ko.applyBindings(null, testNode); - - expect(testNode.childNodes[0].childNodes[0].innerHTML).to.deep.equal("456"); - expect(testNode.childNodes[1].innerHTML).to.deep.equal("123"); - }); - - it('Should not be allowed to have multiple bindings on the same element that claim to control descendant bindings', function() { - ko.bindingHandlers.test1 = { - init: function() { return { controlsDescendantBindings : true } } - }; - ko.bindingHandlers.test2 = ko.bindingHandlers.test1; - testNode.innerHTML = "
"; - expect(function () { - ko.applyBindings(null, testNode); - }).to.throw("Multiple bindings (test1 and test2) are trying to control descendant bindings of the same element."); - }); - - it('Should use properties on the view model in preference to properties on the binding context', function() { - testNode.innerHTML = "
"; - var outerNode = document.createElement("div"); - document.body.appendChild(outerNode); - after(function () { document.body.removeChild(outerNode); }); - ko.applyBindings({ someProp: 'Outer value' }, outerNode); - var outerContext = ko.contextFor(outerNode); - ko.cleanNode(outerNode); - ko.applyBindings(outerContext.createChildContext({ someProp: 'Inner value' }), testNode); - expectContainText(testNode, "Inner value"); - }); - - it('Should be able to extend a binding context, adding new custom properties, without mutating the original binding context', function() { - ko.bindingHandlers.addCustomProperty = { - init: function(element, valueAccessor, allBindings, viewModel, bindingContext) { - ko.applyBindingsToDescendants(bindingContext.extend({ '$customProp': 'my value' }), element); - return { controlsDescendantBindings : true }; + } + } + ko.virtualElements.allowedBindings['test'] = true + + testNode.innerHTML = 'Hello Some text Goodbye' + ko.applyBindings(null, testNode) + + expect(countNodes).to.deep.equal(1) + expectContainText(testNode, 'Hello new text Goodbye') + }) + + it('Should only bind containerless binding once inside template', function () { + var initCalls = 0 + ko.bindingHandlers.test = { + init: function () { + initCalls++ + } + } + ko.virtualElements.allowedBindings['test'] = true + + testNode.innerHTML = 'Hello Some text Goodbye' + ko.applyBindings(null, testNode) + + expect(initCalls).to.deep.equal(1) + expectContainText(testNode, 'Hello Some text Goodbye') + }) + + it('Bindings in containerless binding in templates should be bound only once', function () { + delete ko.bindingHandlers.nonexistentHandler + var initCalls = 0 + ko.bindingHandlers.test = { + init: function () { + initCalls++ + } + } + testNode.innerHTML = + "
xxx
" + ko.applyBindings({}, testNode) + expect(initCalls).to.deep.equal(1) + }) + + it('Should automatically bind virtual descendants of containerless markers if no binding controlsDescendantBindings', function () { + testNode.innerHTML = + 'Hello Some text Goodbye' + ko.applyBindings(null, testNode) + expectContainText(testNode, 'Hello WasBound Goodbye') + }) + + it('Should be able to set and access correct context in custom containerless binding', function () { + ko.bindingHandlers.bindChildrenWithCustomContext = { + init: function (element, valueAccessor, allBindings, viewModel, bindingContext) { + var innerContext = bindingContext.createChildContext({ myCustomData: 123 }) + ko.applyBindingsToDescendants(innerContext, element) + return { controlsDescendantBindings: true } + } + } + ko.virtualElements.allowedBindings['bindChildrenWithCustomContext'] = true + + testNode.innerHTML = 'Hello
Some text
Goodbye' + ko.applyBindings(null, testNode) + + expect(ko.dataFor(testNode.childNodes[2]).myCustomData).to.deep.equal(123) + }) + + it('Should be able to set and access correct context in nested containerless binding', function () { + delete ko.bindingHandlers.nonexistentHandler + ko.bindingHandlers.bindChildrenWithCustomContext = { + init: function (element, valueAccessor, allBindings, viewModel, bindingContext) { + var innerContext = bindingContext.createChildContext({ myCustomData: 123 }) + ko.applyBindingsToDescendants(innerContext, element) + return { controlsDescendantBindings: true } + } + } + + testNode.innerHTML = + "Hello
Some text
Goodbye" + ko.applyBindings(null, testNode) + + expect(ko.dataFor(testNode.childNodes[1].childNodes[0]).myCustomData).to.deep.equal(123) + expect(ko.dataFor(testNode.childNodes[1].childNodes[1]).myCustomData).to.deep.equal(123) + }) + + it('Should be able to access custom context variables in child context', function () { + ko.bindingHandlers.bindChildrenWithCustomContext = { + init: function (element, valueAccessor, allBindings, viewModel, bindingContext) { + var innerContext = bindingContext.createChildContext({ myCustomData: 123 }) + innerContext.customValue = 'xyz' + ko.applyBindingsToDescendants(innerContext, element) + return { controlsDescendantBindings: true } + } + } + + testNode.innerHTML = + "Hello
Some text
Goodbye" + ko.applyBindings(null, testNode) + + expect(ko.contextFor(testNode.childNodes[1].childNodes[0]).customValue).to.deep.equal('xyz') + expect(ko.dataFor(testNode.childNodes[1].childNodes[1])).to.deep.equal(123) + expect(ko.contextFor(testNode.childNodes[1].childNodes[1]).$parent.myCustomData).to.deep.equal(123) + expect(ko.contextFor(testNode.childNodes[1].childNodes[1]).$parentContext.customValue).to.deep.equal('xyz') + }) + + it('Should be able to use value-less binding in containerless binding', function () { + var initCalls = 0 + ko.bindingHandlers.test = { + init: function () { + initCalls++ + } + } + ko.virtualElements.allowedBindings['test'] = true + + testNode.innerHTML = 'Hello Some text Goodbye' + ko.applyBindings(null, testNode) + + expect(initCalls).to.deep.equal(1) + expectContainText(testNode, 'Hello Some text Goodbye') + }) + + it('Should not allow multiple applyBindings calls for the same element', function () { + testNode.innerHTML = '
' + + // First call is fine + ko.applyBindings({}, testNode) + + // Second call throws an error + expect(function () { + ko.applyBindings({}, testNode) + }).to.throw('You cannot apply bindings multiple times to the same element.') + }) + + it('Should allow multiple applyBindings calls for the same element if cleanNode is used', function () { + testNode.innerHTML = '
' + + // First call + ko.applyBindings({}, testNode) + + // cleanNode called before second call + ko.cleanNode(testNode) + ko.applyBindings({}, testNode) + // Should not throw any errors + }) + + it('Should allow multiple applyBindings calls for the same element if subsequent call provides a binding', function () { + testNode.innerHTML = '
' + + // First call uses data-bind + ko.applyBindings({}, testNode) + + // Second call provides a binding + ko.applyBindingsToNode(testNode, { visible: false }, {}) + // Should not throw any errors + }) + + it('Should allow multiple applyBindings calls for the same element if initial call provides a binding', function () { + testNode.innerHTML = '
' + + // First call provides a binding + ko.applyBindingsToNode(testNode, { visible: false }, {}) + + // Second call uses data-bind + ko.applyBindings({}, testNode) + // Should not throw any errors + }) + + describe('Should not bind against text content inside restricted elements', function () { + beforeEach(function () { + restoreAfter(ko.bindingProvider, 'instance') + + // Developers won't expect or want binding to mutate the contents of

Goodbye

"; - ko.applyBindings({ sometext: 'hello' }, testNode); - expectContainHtml(testNode, '

replaced

replaced

'); - }); - - - it('

Goodbye

"; - ko.applyBindings({ sometext: 'hello' }, testNode); - expectContainHtml(testNode, '

replaced

replaced

'); - }); - - it('