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
"
+ 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 =
+ ''
+
+ 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 = 'xx'
+ 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 = 'xx'
+ 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 = 'xx'
+ 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
";
- 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 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 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('