diff --git a/.github/workflows/lint-and-typecheck.yml b/.github/workflows/lint-and-typecheck.yml index d698e72d..936716c0 100644 --- a/.github/workflows/lint-and-typecheck.yml +++ b/.github/workflows/lint-and-typecheck.yml @@ -21,11 +21,8 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile - - name: Check Prettier - run: bun run format - - - name: Check ESLint - run: bun run lint + - name: Check Biome (lint + format) + run: bunx @biomejs/biome ci . - name: Build (required for tsc) run: bun run build diff --git a/.prettierignore b/.prettierignore deleted file mode 100644 index bbb279fb..00000000 --- a/.prettierignore +++ /dev/null @@ -1,16 +0,0 @@ -# Ignore artifacts: -builds -docs -coverage -.vscode -.devcontainer -node_modules -tools -tko.io -**/dist/* -package.json -package-lock.json -.github/** -*.json -*.md -*.config.js \ No newline at end of file diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index d57b50c3..00000000 --- a/.prettierrc +++ /dev/null @@ -1,10 +0,0 @@ -{ - "semi": false, - "trailingComma": "none", - "singleQuote": true, - "arrowParens": "avoid", - "printWidth": 120, - "proseWrap": "never", - "experimentalOperatorPosition": "start", - "objectWrap": "collapse" -} diff --git a/AGENTS.md b/AGENTS.md index 5d4eab70..0459b53f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,10 +38,11 @@ All commands use Bun. Run from the repo root: bun install # Install all dependencies (uses Bun workspaces) bun run build # Build all packages (ESM, CommonJS, MJS, browser) bun run test # Run all tests (Vitest, headless Chromium via Playwright) -bun run lint # Run ESLint -bun run lint:fix # Run ESLint with auto-fix -bun run format # Check Prettier formatting -bun run format:fix # Fix Prettier formatting +bun run check # Run Biome (lint + format) +bun run lint # Run Biome lint only +bun run lint:fix # Run Biome lint with auto-fix +bun run format # Check Biome formatting +bun run format:fix # Fix Biome formatting bun run tsc # TypeScript type-check (no emit) bun run dts # Generate TypeScript declaration files bun run clean # Clean dist/ and coverage/ dirs @@ -62,12 +63,11 @@ because TKO does low-level DOM manipulation, MutationObserver, and event handlin ## Code Style -- **Formatter**: Prettier — no semicolons, single quotes, trailing commas: none, 120 char width -- **Linter**: ESLint with typescript-eslint (flat config) -- **Editor**: 2-space indentation for JS/TS, LF line endings -- See `.prettierrc` and `eslint.config.js` for full config +- **Linter + Formatter**: Biome — single Rust-native tool replacing ESLint + Prettier +- **Style**: no semicolons, single quotes, trailing commas: none, 120 char width, 2-space indent, LF line endings +- See `biome.json` for full config -Run `bun run format:fix && bun run lint:fix` before committing. +Run `bun run lint:fix` before committing. ## TypeScript @@ -102,7 +102,7 @@ GitHub Actions workflows (`.github/workflows/`): |----------|---------|---------| | `main-build.yml` | Push to main | Build + audit + headless test | | `test-headless.yml` | PRs | Matrix test (Chrome, Firefox, jQuery) | -| `lint-and-typecheck.yml` | PRs | Prettier + ESLint + tsc (combined) | +| `lint-and-typecheck.yml` | PRs | Biome + tsc (lint, format, typecheck) | | `publish-check.yml` | PRs | Verify packages are publishable | | `release.yml` | Tag push (`v*`) | Changeset version PRs + npm publish + GitHub release creation | | `github-release.yml` | Manual fallback | Backfill a GitHub release/tag for a published `main` commit if automatic release creation needs a retry | diff --git a/biome.json b/biome.json new file mode 100644 index 00000000..05ed00dd --- /dev/null +++ b/biome.json @@ -0,0 +1,76 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.4.11/schema.json", + "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true }, + "files": { + "ignoreUnknown": true, + "includes": ["packages/**", "builds/**", "tools/**", "global.d.ts", "vitest.config.ts"] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 120, + "lineEnding": "lf" + }, + "linter": { + "enabled": true, + "includes": ["**/*.ts"], + "rules": { + "recommended": true, + "complexity": { + "noUselessEscapeInRegex": "off", + "noArguments": "off", + "noBannedTypes": "off", + "useArrowFunction": "off", + "noThisInStatic": "off" + }, + "correctness": { + "noUnusedVariables": "warn", + "noUnusedImports": "warn", + "noUnusedFunctionParameters": "warn" + }, + "style": { + "useConst": "off", + "useArrayLiterals": "off", + "noNamespace": "off", + "noNonNullAssertion": "off" + }, + "suspicious": { + "noExplicitAny": "off", + "noAssignInExpressions": "off", + "noMisleadingCharacterClass": "off", + "noDoubleEquals": "off", + "noRedeclare": "error", + "noShadowRestrictedNames": "off", + "noControlCharactersInRegex": "off", + "noEmptyBlockStatements": "off", + "noPrototypeBuiltins": "off", + "noFallthroughSwitchClause": "error", + "noImplicitAnyLet": "off", + "noTemplateCurlyInString": "off", + "useIterableCallbackReturn": "off", + "noGlobalIsNan": "off", + "noGlobalIsFinite": "off", + "noThenProperty": "off", + "useGetterReturn": "error" + }, + "performance": { + "noDelete": "off" + }, + "security": { + "noGlobalEval": "off" + } + } + }, + "javascript": { + "formatter": { + "semicolons": "asNeeded", + "trailingCommas": "none", + "quoteStyle": "single", + "arrowParentheses": "asNeeded" + } + }, + "assist": { + "enabled": false + } +} 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('