├── .babelrc ├── .bazelignore ├── .bazelrc ├── .circleci └── config.yml ├── .eslintrc ├── .gitignore ├── .idea ├── .gitignore ├── codeStyles ├── misc.xml ├── modules.xml └── vcs.xml ├── AUTHORS ├── BUILD ├── CHANGELOG.md ├── CONTRIBUTING ├── CONTRIBUTORS ├── ECOSYSTEM.md ├── LICENSE ├── README.md ├── WORKSPACE ├── conf └── karma.conf.js ├── constants.bzl ├── demo ├── base_component.js ├── customelement.html ├── demo_utils.js ├── input.html ├── keys.html └── reorder │ ├── index.html │ └── reorder_list.js ├── incremental-dom.iml ├── index.ts ├── node_externs.js ├── package-lock.json ├── package.json ├── perf ├── create-tests.js ├── creation-innerhtml.js ├── creation-js.js ├── list │ ├── add-start.js │ ├── creation.js │ ├── css │ │ └── style.css │ ├── index.html │ ├── remove-start.js │ ├── renderer.js │ ├── selection-raf.js │ ├── selection.js │ └── setup.js ├── mutation │ ├── creation.js │ ├── css │ │ └── style.css │ ├── high-raf.js │ ├── high.js │ ├── index.html │ ├── renderer.js │ └── setup.js ├── samples.js ├── stats.js └── util.js ├── release ├── BUILD └── debug.ts ├── rollup.config.js ├── rules_nodejs_pr915.patch ├── src ├── BUILD ├── assertions.ts ├── attributes.ts ├── changes.ts ├── context.ts ├── core.ts ├── debug.ts ├── diff.ts ├── dom_util.ts ├── global.ts ├── node_data.ts ├── nodes.ts ├── notifications.ts ├── symbols.ts ├── types.ts ├── util.ts └── virtual_elements.ts ├── test ├── .eslintrc ├── BUILD ├── functional │ ├── applyStatics_spec.ts │ ├── attributes_spec.ts │ ├── buffered_attributes_spec.ts │ ├── conditional_rendering_spec.ts │ ├── constructors_spec.ts │ ├── currentElement_spec.ts │ ├── currentPointer_spec.ts │ ├── element_creation_spec.ts │ ├── errors_spec.ts │ ├── formatters_spec.ts │ ├── hooks_spec.ts │ ├── importing_element_spec.ts │ ├── keyed_items_spec.ts │ ├── patchConfig_matches_spec.ts │ ├── patchinner_spec.ts │ ├── patchouter_spec.ts │ ├── skipNode_spec.ts │ ├── skip_spec.ts │ ├── styles_spec.ts │ ├── text_nodes_spec.ts │ └── virtual_attributes_spec.ts ├── integration │ └── keyed_items_spec.ts ├── karma.conf.js ├── unit │ ├── changes_spec.ts │ └── diff_spec.ts └── util │ ├── dom.ts │ └── globals.js └── tsconfig.json /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "check-es2015-constants", 4 | "transform-es2015-arrow-functions", 5 | "transform-es2015-block-scoping", 6 | "transform-es2015-computed-properties", 7 | "transform-es2015-template-literals" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /.bazelignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.bazelrc: -------------------------------------------------------------------------------- 1 | # Common Bazel settings for JavaScript/NodeJS workspaces 2 | # This rc file is automatically discovered when Bazel is run in this workspace, 3 | # see https://docs.bazel.build/versions/master/guide.html#bazelrc 4 | # 5 | # The full list of Bazel options: https://docs.bazel.build/versions/master/command-line-reference.html 6 | 7 | # Bazel will create symlinks from the workspace directory to output artifacts. 8 | # Build results will be placed in a directory called "dist/bin" 9 | # Other directories will be created like "dist/testlogs" 10 | # Be aware that this will still create a bazel-out symlink in 11 | # your project directory, which you must exclude from version control and your 12 | # editor's search path. 13 | build --symlink_prefix=dist/ 14 | # To disable the symlinks altogether (including bazel-out) you can use 15 | # build --symlink_prefix=/ 16 | # however this makes it harder to find outputs. 17 | 18 | # Specifies desired output mode for running tests. 19 | # Valid values are 20 | # 'summary' to output only test status summary 21 | # 'errors' to also print test logs for failed tests 22 | # 'all' to print logs for all tests 23 | # 'streamed' to output logs for all tests in real time 24 | # (this will force tests to be executed locally one at a time regardless of --test_strategy value). 25 | test --test_output=errors 26 | 27 | # Support for debugging NodeJS tests 28 | # Add the Bazel option `--config=debug` to enable this 29 | test:debug --test_arg=--node_options=--inspect-brk --test_output=streamed --test_strategy=exclusive --test_timeout=9999 --nocache_test_results 30 | 31 | # Turn off legacy external runfiles 32 | # This prevents accidentally depending on this feature, which Bazel will remove. 33 | run --nolegacy_external_runfiles 34 | test --nolegacy_external_runfiles 35 | 36 | # Prevent TypeScript worker seeing files not declared as inputs 37 | build --worker_sandboxing 38 | 39 | # Turn on --incompatible_strict_action_env which was on by default 40 | # in Bazel 0.21.0 but turned off again in 0.22.0. Follow 41 | # https://github.com/bazelbuild/bazel/issues/7026 for more details. 42 | # This flag is needed to so that the bazel cache is not invalidated 43 | # when running bazel via `yarn bazel`. 44 | # See https://github.com/angular/angular/issues/27514. 45 | build --incompatible_strict_action_env 46 | run --incompatible_strict_action_env 47 | 48 | # Load any settings specific to the current user. 49 | # .bazelrc.user should appear in .gitignore so that settings are not shared with team members 50 | # This needs to be last statement in this 51 | # config, as the user configuration should be able to overwrite flags from this file. 52 | # See https://docs.bazel.build/versions/master/best-practices.html#bazelrc 53 | # (Note that we use .bazelrc.user so the file appears next to .bazelrc in directory listing, 54 | # rather than user.bazelrc as suggested in the Bazel docs) 55 | try-import %workspace%/.bazelrc.user 56 | 57 | # Prevent TS worker from trying to expand the `includes` section in tsconfig.json. 58 | # It would find the "test/*.ts" reference when compiling //src:src, and the FileCache will then error 59 | # when TS attempts to read one of these files that doesn't belong in the compilation. 60 | build --worker_sandboxing 61 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Cache key for CircleCI. We want to invalidate the cache whenever the Bazel workspace or the 2 | # NPM dependencies changed. 3 | var_1: &cache_key incremental-dom-{{ checksum "WORKSPACE" }}-{{ checksum "package-lock.json" }} 4 | # Default docker image for CircleCI jobs with Bazel installed. The version of Bazel is controlled 5 | # by the docker image version. e.g "google/bazel:0.22.0" installs Bazel v0.22.0. When updating this 6 | # version, consider updating the minimum required Bazel version in the "WORKSPACE" file. 7 | var_2: &default_docker_image l.gcr.io/google/bazel:0.28.0 8 | 9 | # Settings common to each job 10 | var_3: &job_defaults 11 | working_directory: ~/incremental-dom 12 | docker: 13 | - image: circleci/node:current-browsers 14 | 15 | # Saves the cache for the current cache key. We store the installed "node_modules" and the Bazel 16 | # repository cache in order to make subsequent builds faster. 17 | var_4: &save_cache 18 | save_cache: 19 | key: *cache_key 20 | paths: 21 | - "node_modules" 22 | - "~/bazel_repository_cache" 23 | 24 | version: 2 25 | 26 | jobs: 27 | setup: 28 | <<: *job_defaults 29 | steps: 30 | - checkout 31 | - restore_cache: 32 | key: *cache_key 33 | - run: npm ci 34 | - *save_cache 35 | 36 | build: 37 | <<: *job_defaults 38 | steps: 39 | - checkout 40 | - restore_cache: 41 | key: *cache_key 42 | - run: ./node_modules/.bin/bazel build ... 43 | - *save_cache 44 | 45 | lint: 46 | <<: *job_defaults 47 | steps: 48 | - checkout 49 | - restore_cache: 50 | key: *cache_key 51 | - run: npm run lint 52 | 53 | test: 54 | <<: *job_defaults 55 | steps: 56 | - checkout 57 | - restore_cache: 58 | key: *cache_key 59 | - run: ./node_modules/.bin/bazel test ... 60 | - *save_cache 61 | 62 | workflows: 63 | version: 2 64 | build_and_test: 65 | jobs: 66 | - setup 67 | - lint: 68 | requires: 69 | - setup 70 | - build: 71 | requires: 72 | - setup 73 | - test: 74 | requires: 75 | - setup 76 | 77 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "plugin:@typescript-eslint/recommended", 4 | "prettier/@typescript-eslint", 5 | "plugin:prettier/recommended" 6 | ], 7 | "parserOptions": { 8 | "ecmaVersion": 6, 9 | "sourceType": "module" 10 | }, 11 | "plugins": [ 12 | "@typescript-eslint", 13 | "prettier" 14 | ], 15 | "env": { 16 | "browser": true 17 | }, 18 | "globals": { 19 | "process": true, 20 | "global": true 21 | }, 22 | "rules": { 23 | "@typescript-eslint/array-type": [2, "generic"], 24 | "@typescript-eslint/explicit-function-return-type": 0, 25 | "@typescript-eslint/no-angle-bracket-type-assertion": 0, 26 | "@typescript-eslint/no-non-null-assertion": 0, 27 | "@typescript-eslint/no-unused-vars": ["warn", { 28 | "varsIgnorePattern": "Def$", 29 | "argsIgnorePattern": "varArgs" 30 | }], 31 | "indent": "off", 32 | "keyword-spacing": [2, {"before": true, "after": true}], 33 | "no-trailing-spaces": 2, 34 | "no-irregular-whitespace": 2, 35 | "no-param-reassign": 2, 36 | "prettier/prettier": "error", 37 | "space-before-blocks": [2, "always"], 38 | "space-before-function-paren": [2, "never"], 39 | "valid-jsdoc": ["error", { 40 | "requireParamDescription": false, 41 | "requireParamType": false, 42 | "requireReturn": false, 43 | "requireReturnDescription": false, 44 | "requireReturnType": false 45 | }] 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | build/ 4 | coverage/ 5 | bazel-* 6 | .DS_Store 7 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Datasource local storage ignored files 5 | /dataSources/ 6 | /dataSources.local.xml 7 | # Editor-based HTTP Client requests 8 | /httpRequests/ 9 | -------------------------------------------------------------------------------- /.idea/codeStyles: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | # This is a list of contributors to Incremental DOM. 2 | 3 | # Names should be added to this file like so: 4 | # Name or Organization 5 | 6 | Google Inc. 7 | -------------------------------------------------------------------------------- /BUILD: -------------------------------------------------------------------------------- 1 | package(default_visibility = ["//:__subpackages__"]) 2 | 3 | load("@npm//@bazel/typescript:index.bzl", "ts_library") 4 | load("@build_bazel_rules_nodejs//:index.bzl", "pkg_npm") 5 | load("@npm//@bazel/rollup:index.bzl", "rollup_bundle") 6 | 7 | ### Produce umd, cjs and esm bundles 8 | 9 | ts_library( 10 | name = "dev", 11 | srcs = ["index.ts"], 12 | tsickle_typed = True, 13 | deps = ["//src"], 14 | ) 15 | 16 | [ 17 | rollup_bundle( 18 | name = "bundle.%s" % format, 19 | args = args, 20 | config_file = "rollup.config.js", 21 | entry_point = ":index.ts", 22 | format = format, 23 | sourcemap = "true", 24 | deps = [ 25 | ":dev", 26 | "@npm//@rollup/plugin-buble", 27 | ], 28 | ) 29 | for format, args in { 30 | "cjs": [], 31 | "esm": [], 32 | "umd": [ 33 | # Downlevel (transpile) to ES5. 34 | "-p", 35 | "@rollup/plugin-buble", 36 | ], 37 | }.items() 38 | ] 39 | 40 | genrule( 41 | name = "incremental-dom", 42 | srcs = [":bundle.umd.js"], 43 | outs = ["dist/incremental-dom.js"], 44 | cmd = "cp $(locations :bundle.umd.js) $@", 45 | ) 46 | 47 | pkg_npm( 48 | name = "npm-umd", 49 | deps = [ 50 | ":incremental-dom", 51 | ], 52 | ) 53 | 54 | genrule( 55 | name = "incremental-dom-cjs", 56 | srcs = [":bundle.cjs.js"], 57 | outs = ["dist/incremental-dom-cjs.js"], 58 | cmd = "cp $(locations :bundle.cjs.js) $@", 59 | ) 60 | 61 | pkg_npm( 62 | name = "npm-cjs", 63 | substitutions = { 64 | "const DEBUG = true;": "const DEBUG = process.env.NODE_ENV != \"production\";", 65 | }, 66 | deps = [ 67 | ":incremental-dom-cjs", 68 | ], 69 | ) 70 | 71 | genrule( 72 | name = "incremental-dom-esm", 73 | srcs = [":bundle.esm.js"], 74 | outs = ["dist/incremental-dom-esm.js"], 75 | cmd = "cp $(locations :bundle.esm.js) $@", 76 | ) 77 | 78 | pkg_npm( 79 | name = "npm-esm", 80 | substitutions = { 81 | "const DEBUG = true;": "const DEBUG = false;", 82 | }, 83 | deps = [ 84 | ":incremental-dom-esm", 85 | ], 86 | ) 87 | 88 | ### Produce minified bundle 89 | 90 | ## Create a second index so that it can have a reference to the release/ directory. 91 | ## Using the same index.ts would cause issues with index.closure.js being created twice. 92 | genrule( 93 | name = "release_index", 94 | srcs = ["index.ts"], 95 | outs = ["release_index.ts"], 96 | cmd = "cat $(location index.ts) | sed -e 's/src/release/g' > $@", 97 | ) 98 | 99 | ts_library( 100 | name = "release", 101 | srcs = [":release_index"], 102 | tsickle_typed = True, 103 | deps = ["//release"], 104 | ) 105 | 106 | rollup_bundle( 107 | name = "min-bundle", 108 | args = [ 109 | # Downlevel (transpile) to ES5. 110 | "-p", 111 | "@rollup/plugin-buble", 112 | ], 113 | config_file = "rollup.config.js", 114 | entry_point = ":release_index.ts", 115 | format = "umd", 116 | deps = [ 117 | ":release", 118 | "@npm//@rollup/plugin-buble", 119 | ], 120 | ) 121 | 122 | ## Need to run uglify to minify instead of using .min.es5umd.js, since it uses 123 | ## Terser, which has some performance issues with the output in how it inlines 124 | ## functions. 125 | genrule( 126 | name = "incremental-dom-min", 127 | srcs = [":min-bundle.js"], 128 | outs = ["dist/incremental-dom-min.js"], 129 | cmd = "$(location node_modules/.bin/uglifyjs) --comments --source-map=url -m -o $@ $(location min-bundle.js)", 130 | tools = ["node_modules/.bin/uglifyjs"], 131 | ) 132 | 133 | pkg_npm( 134 | name = "npm-min", 135 | deps = [ 136 | ":incremental-dom-min", 137 | ], 138 | ) 139 | 140 | ### Emit TS files 141 | 142 | pkg_npm( 143 | name = "npm", 144 | package_name = "incremental-dom", 145 | srcs = [ 146 | "index.ts", 147 | "package.json", 148 | "//src:all_files", 149 | ], 150 | nested_packages = [ 151 | ":npm-cjs", 152 | ":npm-esm", 153 | ":npm-min", 154 | ":npm-umd", 155 | ], 156 | ) 157 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## 0.7.0 4 | 5 | - Added an option to specify which attribute to use for a key when importing 6 | DOM nodes 7 | - Improve handling of cached value for `Text` nodes when using formatting 8 | functions 9 | - Added an option to specify a custom matching function via `createPatchInner` 10 | and `createPatchOuter` 11 | - Added a call flow to open an element, then apply attributes using the new 12 | `applyStatics` and `applyAttrs` functions along with the existing `open` and 13 | `close` calls, allowing you to use information from the element in when 14 | specifying the attributes 15 | - Improved performance of the `elementOpenStart`, `attr`, `elementOpenEnd` call 16 | sequence 17 | - Fix assertion when updating the `style` of an element from another `Document` 18 | 19 | 20 | ## 0.6.0 21 | 22 | - Added support for MathML 23 | - Removed restriction that keys are unique within a parent Element 24 | - Added better handling of statics when importing an Element 25 | - Allow importing of server-side rendered DOM without transmitting keys 26 | - This relies on the first patch being a no-op in order for things to line up 27 | correctly 28 | - `elementOpen` and friends can now take a CustomElement constructor instead of 29 | a tag name 30 | - Added an output target that generates Closure Compiler typed code 31 | 32 | ## 0.5.0 33 | 34 | - Removed `symbols.placeholder` 35 | - Removed `#elementPlaceholder` 36 | - Fixed camelCase SVG tags not being imported correctly 37 | - Fixed bug where focus was not being maintained on a keyed item 38 | - Changed `#patchOuter` to allow removing or replacing the node with another 39 | single node 40 | - Changed keyed items to allow being replaced with a different tag 41 | - Added `#importNode` function to help perserve DOM mutations made on DOM 42 | before the first patch 43 | - Added `#currentPoiner` - the next Node Incrementl DOM will patch 44 | - Added `#skipNode` - skips the node pointed to by `#currentPointer` 45 | - Added support for dashed CSS properties (e.g. `background-color`), including 46 | CSS custom properties, in a style object 47 | 48 | ## 0.4.0 49 | 50 | - Deprecated `symbols.placeholder`, will be removed in 0.5.0 51 | - Fixed performance issue with `text` call 52 | - Added `patchOuter` function, which patches an Element rather than an 53 | Element's children 54 | - Added `patchInner` as an alias of `patch` 55 | - Added support for `xlink:href` and other `xlink:` attributes 56 | 57 | ## 0.3.0 58 | 59 | - Added `skip` function 60 | - Added `currentElement` function 61 | - Added more asserts 62 | 63 | ## 0.2.0 64 | 65 | - Added asserts to non-minified build to help ensure proper usage 66 | - Added support for creating SVG elements 67 | - Fixed two bugs related to attributes not being properly updated or removed 68 | - Changed `null` and `undefined` keys to be treated as equivalent 69 | - Added an optional parameter to patch to pass data 70 | - Changed elementOpen, elementVoid, elementClose to return the associated Element 71 | - Added hooks to specify how attributes/properties are set 72 | - Changed main file to not include a UMD header 73 | - Added formatting function variable arguments to the `text` function 74 | 75 | 76 | ## 0.1.0 77 | 78 | - Initial release 79 | -------------------------------------------------------------------------------- /CONTRIBUTING: -------------------------------------------------------------------------------- 1 | Google Individual Contributor License 2 | 3 | In all cases, contributors must sign a contributor license agreement, 4 | either for an individual or corporation, before a patch can be 5 | accepted. Please fill out the agreement for an individual or a 6 | corporation, as appropriate. 7 | 8 | https://developers.google.com/open-source/cla/individual 9 | https://developers.google.com/open-source/cla/corporate 10 | 11 | If you or your organization is not listed there already, you should 12 | add an entry to the CONTRIBUTORS file as part of your patch. 13 | 14 | If you plan to add a significant component or large chunk of code, it 15 | is recommended to bring it up on the discussion list for a design 16 | discussion before writing code. 17 | 18 | If appropriate, write a unit test that demonstrates your patch. Tests are the 19 | best way to ensure that future contributors do not break your code 20 | accidentally. 21 | 22 | To change the Incremental DOM source, you must submit a pull request 23 | in GitHub. See the GitHub documentation here: 24 | 25 | https://help.github.com/categories/63/articles 26 | 27 | Incremental DOM developers monitor outstanding pull requests. They may 28 | request changes on the pull request before accepting. They will also 29 | verify that the CLA has been signed. 30 | 31 | Oftentimes, the pull request will not be directly merged, but patched to 32 | the internal Google codebase to verify that unit and integration tests 33 | will Closure pass before submitting (and optionally make changes to 34 | the patch to match style, fix text, or to make the code or comments 35 | clearer). In this case, the issue associated with the pull request 36 | will be closed when the patch pushed to the repository via the MOE 37 | (Make Open Easy) system. 38 | 39 | https://code.google.com/p/moe-java/ 40 | -------------------------------------------------------------------------------- /CONTRIBUTORS: -------------------------------------------------------------------------------- 1 | # Names should be added to this file as: 2 | # Name 3 | 4 | Sepand Parhami 5 | Justin Ridgewell 6 | Markus Kobler 7 | Jader Tao 8 | Nick Excell 9 | -------------------------------------------------------------------------------- /ECOSYSTEM.md: -------------------------------------------------------------------------------- 1 | # The Incremental DOM Ecosystem 2 | 3 | The page contains a list of tools and libraries that use or can be used with Incremental DOM. If you have something that you have worked on and would like to share, please feel free to send us a pull request to add it here. 4 | 5 | ## Templating Languages 6 | 7 | ### Closure Compiler Templates 8 | 9 | We are building a new JavaScript backend for the 10 | [Closure Templates](https://developers.google.com/closure/templates/) templating 11 | language. Follow along on [Github](https://github.com/google/closure-templates/). 12 | 13 | ``` 14 | {template .helloWorld} 15 |

Hello World!

16 | {/template} 17 | ``` 18 | 19 | ### JSX 20 | 21 | You can also use React's [JSX syntax](https://facebook.github.io/jsx/) using this 22 | [Babel plugin](https://github.com/babel-plugins/babel-plugin-incremental-dom). 23 | 24 | ```js 25 | function render() { 26 | return

Hello World

27 | } 28 | ``` 29 | 30 | ### superviews.js 31 | 32 | [superviews.js](https://github.com/davidjamesstone/superviews.js) is a template language that closely maps to the incremental-dom API. It includes conditionals, iteration, interpolation and supported output for both ES6 and CommonJS. 33 | 34 | Try it out [live in your browser](http://davidjamesstone.github.io/superviews.js/playground/) 35 | 36 | ```html 37 |

38 | {name} 39 |

40 | ``` 41 | 42 | ### starplate 43 | 44 | [starplate](https://github.com/littlstar/starplate) is a fast template and view engine built on top of the incremental-dom API. It makes use of ES6 template strings for interpolation, [parse5](https://github.com/inikulin/parse5) for DOM traversal, and incremental-dom for DOM patches. 45 | 46 | Consider the following rudimentary example for rendering and updating a clock. 47 | 48 | ```js 49 | import {View} from 'starplate'; 50 | const clock = new View('
Time ${time}
') 51 | clock.render(document.body); 52 | setInterval(_ => clock.update({time: Date()}, 1000); 53 | ``` 54 | 55 | ### khufu 56 | 57 | [khufu](http://github.com/tailhook/khufu) is a template engine with a concise indentation-based syntax, and integration with [redux](http://github.com/rackt/redux): 58 | 59 | ```html 60 | view main(): 61 | 62 | store @counter = Counter 63 | 64 | 65 | link {click} incr(1) -> @counter 66 | ``` 67 | 68 | Khufu is a little bit more than a template engine as it allows you to add create local redux stores. This allows tracking local state like whether an accordion is expanded or whether a tooltip is shown without additional javascript boilerplate. The library implements useful scoping rules for stores as well as for styles included into the template. 69 | 70 | And khufu supports **hot reload**! 71 | 72 | ### jsonml2idom 73 | 74 | [jsonml2idom](https://github.com/paolocaminiti/jsonml2idom) - JSONML to Incremental DOM interpreter. 75 | ```js 76 | function app(state) { 77 | return ['h1', 'Hello World!'] 78 | } 79 | 80 | IncrementalDOM.patch(root, jsonml2idom, app(state)) 81 | ``` 82 | 83 | ### incremental-dom-loader 84 | 85 | [incremental-dom-loader](https://github.com/helloIAmPau/incremental-dom-loader) - An incremental-dom loader for webpack. It transpiles an HTML template file into an incremental-dom script. 86 | 87 | ```html 88 |

Hello!

89 | 90 | 91 | 92 |

${ value.title }

93 |

${ value.text }

94 | 95 |
96 |
97 | ``` 98 | 99 | ```js 100 | var id = require('incremental-dom'); 101 | 102 | module.exports = function(state) { 103 | id.elementOpen('h1', 'hio0k', []); 104 | id.text(`Hello!`); 105 | id.elementClose('h1'); 106 | if(state.check()) { 107 | for(const key of Object.keys(state.items)) { 108 | const value = state.items[key]; 109 | id.elementOpen('h2', `ncj5k-${ key }`, []); 110 | id.text(`${ value.title }`); 111 | id.elementClose('h2'); 112 | id.elementOpen('p', `jde79-${ key }`, []); 113 | id.text(`${ value.text }`); 114 | id.elementClose('p'); 115 | id.elementOpen('button', `eima7-${ key }`, [], 'onclick', state.love); 116 | id.text(`Show Love!`); 117 | id.elementClose('button'); 118 | } 119 | } 120 | } 121 | ``` 122 | 123 | ### Create your own 124 | 125 | If you work on a templating language we'd love to see Incremental DOM adopted as 126 | an alternative backend for it. This isn’t easy, we are still working on ours and 127 | will for a while, but we're super happy to help with it. 128 | 129 | Here's an [example](https://gist.github.com/sparhami/197f3b947712998639eb). 130 | 131 | ## Libraries 132 | 133 | ### Skate 134 | 135 | [Skate](https://github.com/skatejs/skatejs) is library that leverages [Incremental DOM](https://github.com/google/incremental-dom) to encourage functional [web components](http://w3c.github.io/webcomponents/explainer/). 136 | 137 | ### FerrugemJS 138 | 139 | [FerrugemJS](https://ferrugemjs.github.io/home-page/) is a library inspired by Aurelia and React using [Incremental DOM](https://github.com/google/incremental-dom) with a easy and intuitive template language. 140 | 141 | ### Metal.js 142 | 143 | [Metal.js](https://github.com/metal/metal.js) is a JavaScript library for building UI components in a solid, flexible way. It leverages [Incremental DOM](https://github.com/google/incremental-dom) and currently supports both [Closure Templates](https://developers.google.com/closure/templates/) and [JSX syntax](https://facebook.github.io/jsx/). 144 | 145 | ### Towser 146 | [Towser](https://github.com/PongoEngine/Towser) is a web framework heavily inspired by the Elm Architechture using Google's Incremental-Dom. It is built to easily nest and compose Render Functions. 147 | 148 | ```haxe 149 | 150 | class Main { 151 | static function main() { 152 | new Towser("app", update, view, {name: "Perdita"}); 153 | } 154 | 155 | public static function view(model:Model) : RenderFunction 156 | { 157 | return div([class_("full-screen"), onclick(SayName.bind(model.name))], [ 158 | h1([], [text("Hello")]), 159 | p([], [text(model.name)]) 160 | ]); 161 | } 162 | 163 | public static function update(msg:Msg, model:Model):Bool { 164 | switch msg { 165 | case SayName(name, e): trace(name); 166 | } 167 | return true; 168 | } 169 | } 170 | 171 | enum Msg { 172 | SayName(name :String, e :MouseEvent); 173 | } 174 | 175 | typedef Model = 176 | { 177 | var name :String; 178 | } 179 | 180 | ``` 181 | Towser can easlily be integrated with your favorite node framework by compiling it with the flag 'backend'. 182 | 183 | ### Falak JS 184 | [Falak JS](https://github.com/falakjs/Falak) is a framework "**for lazy people**" built on top of Incremental Dom with a fully maximized and easy usage. 185 | 186 | ```html 187 | 188 | 189 |

Welcome boss

190 | 191 |

Welcome {{ this.user.name }}

192 | 193 |

Welcome visitor

194 | 195 | 196 |
    197 |
  • 198 | {{ user.name }} 199 |
  • 200 | 201 |
  • 202 | 203 |
  • 204 | {{ index }}- {{ user.name }} 205 |
  • 206 | 207 |
  • ...
  • 208 |
209 | 210 | 211 | .... 212 | ``` 213 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![CircleCI](https://circleci.com/gh/google/incremental-dom.svg?style=svg)](https://circleci.com/gh/google/incremental-dom) 2 | 3 | # Incremental DOM 4 | 5 | ## Overview 6 | 7 | Incremental DOM is a library for building up DOM trees and updating them in-place when data changes. It differs from the established virtual DOM approach in that no intermediate tree is created (the existing tree is mutated in-place). This approach significantly reduces memory allocation and GC thrashing for incremental updates to the DOM tree therefore increasing performance significantly in some cases. 8 | 9 | Incremental DOM is primarily intended as a compilation target for templating languages. It could be used to implement a higher level API for human consumption. The API was carefully designed to minimize heap allocations and where unavoidable ensure that as many objects as possible can be de-allocated by incremental GC. One unique feature of its API is that it separates opening and closing of tags so that it is suitable as a compilation target for templating languages that allow (temporarily) unbalanced HTML in templates (e.g. tags that are opened and closed in separate templates) and arbitrary logic for creating HTML attributes. 10 | *Think of it as ASM.dom.* 11 | 12 | ## Supported Browsers 13 | 14 | Incremental DOM supports IE9 and above. 15 | 16 | ## Usage 17 | 18 | HTML is expressed in Incremental DOM using the `elementOpen`, `elementClose`, `elementVoid` and `text` methods. Consider the following example: 19 | 20 | ```javascript 21 | var IncrementalDOM = require('incremental-dom'), 22 | elementOpen = IncrementalDOM.elementOpen, 23 | elementClose = IncrementalDOM.elementClose, 24 | elementVoid = IncrementalDOM.elementVoid, 25 | text = IncrementalDOM.text; 26 | 27 | function render(data) { 28 | elementVoid('input', '', [ 'type', 'text' ]); 29 | elementOpen('div', '', null); 30 | if (data.someCondition) { 31 | text(data.text); 32 | } 33 | elementClose('div'); 34 | } 35 | ``` 36 | 37 | To render or update an existing DOM node, the patch function is used: 38 | 39 | 40 | ```javascript 41 | var patch = require('incremental-dom').patch; 42 | 43 | var data = { 44 | text: 'Hello World!', 45 | someCondition: true 46 | }; 47 | 48 | patch(myElement, function() { 49 | render(data); 50 | }); 51 | 52 | data.text = 'Hello World!'; 53 | 54 | patch(myElement, function() { 55 | render(data); 56 | }); 57 | ``` 58 | 59 | ## Templating Languages and Libraries 60 | 61 | [Check out](ECOSYSTEM.md) what others having been doing with Incremental DOM. 62 | 63 | ## Docs 64 | 65 | - [Introducing Incremental Dom](https://medium.com/google-developers/introducing-incremental-dom-e98f79ce2c5f) 66 | - [Docs and demos](http://google.github.io/incremental-dom/) 67 | 68 | ## Getting Incremental DOM 69 | 70 | ### Via CDN 71 | 72 | https://ajax.googleapis.com/ajax/libs/incrementaldom/0.5.1/incremental-dom.js 73 | https://ajax.googleapis.com/ajax/libs/incrementaldom/0.5.1/incremental-dom-min.js 74 | 75 | ### Using npm 76 | 77 | ```sh 78 | npm install incremental-dom 79 | ``` 80 | 81 | ## Development 82 | 83 | To install the required development packages, run the following command: 84 | 85 | ```sh 86 | npm i 87 | ``` 88 | 89 | ### Running tests 90 | 91 | To run once: 92 | 93 | ```sh 94 | ./node_modules/.bin/bazelisk test ... 95 | ``` 96 | 97 | To run on change: 98 | 99 | ```sh 100 | ./node_modules/.bin/ibazel run //test:unit_tests 101 | ``` 102 | 103 | ### Building 104 | 105 | To build once: 106 | 107 | ```sh 108 | ./node_modules/.bin/bazelisk build ... 109 | ``` 110 | 111 | To build on change: 112 | 113 | ```sh 114 | ./node_modules/.bin/ibazel build ... 115 | ``` 116 | -------------------------------------------------------------------------------- /WORKSPACE: -------------------------------------------------------------------------------- 1 | workspace( 2 | name = 'incremental_dom', 3 | managed_directories = {"@npm": ["node_modules"]}, 4 | ) 5 | 6 | load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive") 7 | 8 | # Fetch rules_nodejs so we can install our npm dependencies 9 | http_archive( 10 | name = "build_bazel_rules_nodejs", 11 | sha256 = "ddb78717b802f8dd5d4c01c340ecdc007c8ced5c1df7db421d0df3d642ea0580", 12 | urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/4.6.0/rules_nodejs-4.6.0.tar.gz"], 13 | ) 14 | 15 | load("@build_bazel_rules_nodejs//:index.bzl", "node_repositories") 16 | 17 | node_repositories(package_json = ["//:package.json"]) 18 | 19 | # Check the bazel version and download npm dependencies 20 | load("@build_bazel_rules_nodejs//:index.bzl", "check_bazel_version", "npm_install") 21 | 22 | # Bazel version must be at least v0.21.0 because: 23 | # - 0.21.0 Using --incompatible_strict_action_env flag fixes cache when running `yarn bazel` 24 | # (see https://github.com/angular/angular/issues/27514#issuecomment-451438271) 25 | check_bazel_version( 26 | message = """ 27 | You don't need to install Bazel on your machine. 28 | Angular has a dependency on the @bazel/bazel package which supplies it. 29 | Try running `npm run bazel` instead. 30 | (If you did run that, check that you've got a fresh `npm install`) 31 | """, 32 | minimum_bazel_version = "0.21.0", 33 | ) 34 | 35 | # Setup the Node.js toolchain & install our npm dependencies into @npm 36 | npm_install( 37 | name = "npm", 38 | package_json = "//:package.json", 39 | package_lock_json = "//:package-lock.json", 40 | ) 41 | 42 | http_archive( 43 | name = "io_bazel_rules_webtesting", 44 | sha256 = "e9abb7658b6a129740c0b3ef6f5a2370864e102a5ba5ffca2cea565829ed825a", 45 | urls = ["https://github.com/bazelbuild/rules_webtesting/releases/download/0.3.5/rules_webtesting.tar.gz"], 46 | ) 47 | 48 | # Set up web testing, choose browsers we can test on 49 | load("@io_bazel_rules_webtesting//web:repositories.bzl", "web_test_repositories") 50 | 51 | web_test_repositories() 52 | 53 | load("@io_bazel_rules_webtesting//web/versioned:browsers-0.3.3.bzl", "browser_repositories") 54 | 55 | browser_repositories( 56 | chromium = True, 57 | firefox = True, 58 | ) 59 | -------------------------------------------------------------------------------- /conf/karma.conf.js: -------------------------------------------------------------------------------- 1 | // Copyright 2018 The Incremental DOM Authors. All Rights Reserved. 2 | /** @license SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | module.exports = function(config) { 5 | config.set({ 6 | 7 | // base path that will be used to resolve all patterns (eg. files, exclude) 8 | basePath: '../', 9 | 10 | 11 | // frameworks to use 12 | // available frameworks: https://npmjs.org/browse/keyword/karma-adapter 13 | frameworks: ['mocha', 'sinon-chai', 'karma-typescript'], 14 | 15 | 16 | // list of files / patterns to load in the browser 17 | files: [ 18 | './src/*.ts', 19 | './test/util/dom.ts', 20 | './test/util/globals.js', 21 | './test/unit/*.ts', 22 | './test/integration/*.ts', 23 | './test/functional/*.ts', 24 | './index.ts', 25 | ], 26 | 27 | customLaunchers: { 28 | chromeNoSandbox: { 29 | base: 'ChromeHeadless', 30 | flags: ['--no-sandbox'] 31 | } 32 | }, 33 | 34 | karmaTypescriptConfig: { 35 | tsconfig: './tsconfig.json', 36 | coverageOptions: {exclude: /.*/}, 37 | }, 38 | 39 | 40 | // list of files / patterns to exclude 41 | exclude: [], 42 | 43 | 44 | // preprocess matching files before serving them to the browser 45 | // available preprocessors: 46 | // https://npmjs.org/browse/keyword/karma-preprocessor 47 | preprocessors: { 48 | './src/*.ts': ['karma-typescript'], 49 | './test/**/*.ts': ['karma-typescript'], 50 | './index.ts': ['karma-typescript'], 51 | }, 52 | 53 | 54 | // test results reporter to use 55 | // possible values: 'dots', 'progress' 56 | // available reporters: https://npmjs.org/browse/keyword/karma-reporter 57 | reporters: ['nyan', 'karma-typescript'], 58 | 59 | 60 | // web server port 61 | port: 9876, 62 | 63 | 64 | // enable / disable colors in the output (reporters and logs) 65 | colors: true, 66 | 67 | 68 | // level of logging 69 | // possible values: config.LOG_DISABLE || config.LOG_ERROR || 70 | // config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG 71 | logLevel: config.LOG_INFO, 72 | 73 | 74 | // enable / disable watching file and executing tests whenever any file 75 | // changes 76 | autoWatch: true, 77 | 78 | 79 | // start these browsers 80 | // available browser launchers: 81 | // https://npmjs.org/browse/keyword/karma-launcher 82 | browsers: ['Chrome'], 83 | 84 | mime: {'text/x-typescript': ['ts', 'tsx']}, 85 | 86 | // Continuous Integration mode 87 | // if true, Karma captures browsers, runs the tests and exits 88 | singleRun: false, 89 | 90 | // Concurrency level 91 | // how many browser should be started simultaneous 92 | concurrency: Infinity 93 | }) 94 | } 95 | -------------------------------------------------------------------------------- /constants.bzl: -------------------------------------------------------------------------------- 1 | RELEASE_FILES = [ 2 | "assertions.ts", 3 | "attributes.ts", 4 | "changes.ts", 5 | "context.ts", 6 | "core.ts", 7 | "diff.ts", 8 | "dom_util.ts", 9 | "global.ts", 10 | "node_data.ts", 11 | "nodes.ts", 12 | "notifications.ts", 13 | "symbols.ts", 14 | "types.ts", 15 | "util.ts", 16 | "virtual_elements.ts", 17 | ] -------------------------------------------------------------------------------- /demo/base_component.js: -------------------------------------------------------------------------------- 1 | const patch = IncrementalDOM.patch; 2 | 3 | const firstUpdate = Symbol('firstUpdate'); 4 | const props = Symbol('props'); 5 | const render = Symbol('render'); 6 | 7 | export class BaseComponent extends HTMLElement { 8 | constructor() { 9 | super(); 10 | 11 | // Create a shadow root for the content of the component to render into 12 | this.attachShadow({mode: 'open'}); 13 | this[firstUpdate] = true; 14 | this[props] = null; 15 | 16 | // Handle lazy upgrade - take the existing props from the Element and 17 | // re-apply them so that they go through (and do not shadow) the setter. 18 | var props = this.props; 19 | if (props) { 20 | delete this.props; 21 | this.props = props; 22 | } 23 | } 24 | 25 | /** 26 | * Renders / diffs the contents of the component's shadow root using the 27 | * render function defined by the component's spec. 28 | */ 29 | [render]() { 30 | patch(this.shadowRoot, this.render.bind(this)); 31 | } 32 | 33 | willReceiveProps() {} 34 | componentDidUpdate() {} 35 | shouldComponentUpdate() { 36 | return true; 37 | } 38 | 39 | /** 40 | * Incremental DOM will update the 'props' property on the DOM node for our 41 | * component. This setter notifies us when the props have changed so that 42 | * we can check if an update is needed, and if so, call render. 43 | */ 44 | set props(newProps) { 45 | this.willReceiveProps(newProps, this.props); 46 | 47 | var shouldUpdate = this[firstUpdate] || 48 | this.shouldComponentUpdate(newProps, this.props); 49 | 50 | this[firstUpdate] = false; 51 | this[props] = newProps; 52 | 53 | if (shouldUpdate) { 54 | this[render](); 55 | this.componentDidUpdate(); 56 | } 57 | } 58 | 59 | get props() { 60 | return this[props]; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /demo/customelement.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 18 | 19 | 20 |
21 | 22 |

23 | The text of the zero-th item is updated and the highlighted item changes. 24 | A small wrapper, using native webcomponents, is used to create a React-like 25 | lifecycle is used to only diff an item that might have changes. 26 |

27 | 28 | 123 | 124 | 125 | -------------------------------------------------------------------------------- /demo/demo_utils.js: -------------------------------------------------------------------------------- 1 | export function before(obj, fnName, cb) { 2 | var old = obj[fnName]; 3 | 4 | obj[fnName] = function() { 5 | cb(); 6 | return old.apply(this, arguments); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /demo/input.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 |
Hello world
A string with more than 4 characters
8 | 9 |

10 | Incremental DOM can update an existing DOM tree. In this demo, the page 11 | contains the initial DOM. After a simulated delay of 500ms, Incremental 12 | DOM performs an initial diff of the subtree. 13 |

14 | 15 | 16 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /demo/keys.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |
7 | 8 | 79 | 80 | 81 | -------------------------------------------------------------------------------- /demo/reorder/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 11 | 12 |
13 | 14 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /demo/reorder/reorder_list.js: -------------------------------------------------------------------------------- 1 | // Based on https://github.com/joshwcomeau/react-flip-move/blob/master/src/FlipMove.js 2 | const { 3 | elementOpen, 4 | elementClose, 5 | currentElement, 6 | } = IncrementalDOM; 7 | 8 | const locations = new WeakMap(); 9 | const initializedEls = new WeakSet(); 10 | 11 | function updateOffset(node) { 12 | const rect = node.getBoundingClientRect(); 13 | const lastLocation = locations.get(node); 14 | const newLocation = { 15 | top: rect.top 16 | }; 17 | 18 | locations.set(node, newLocation); 19 | 20 | if (!lastLocation) { 21 | return; 22 | } 23 | 24 | const deltaY = lastLocation.top - newLocation.top; 25 | node.style.transitionDuration = '0ms'; 26 | node.style.transform = `translate(0, ${deltaY}px)`; 27 | } 28 | 29 | function triggerAnimation(nodes) { 30 | requestAnimationFrame(_ => { 31 | requestAnimationFrame(_ => { 32 | nodes.forEach(node => { 33 | node.style.transitionDuration = ''; 34 | node.style.transform = ''; 35 | }); 36 | }); 37 | }); 38 | } 39 | 40 | function attachList(el) { 41 | if (initializedEls.has(el)) { 42 | return; 43 | } 44 | 45 | new MutationObserver(_ => { 46 | const children = [...el.childNodes]; 47 | children.forEach(updateOffset); 48 | triggerAnimation(children); 49 | }).observe(el, {childList: true}); 50 | 51 | initializedEls.add(el); 52 | } 53 | 54 | export function reorderList(fn) { 55 | // Treating element as a component, so it should have a custom tag so 56 | // that the mutation observer we create is never reused with a different 57 | // logical element. 58 | elementOpen('x-reorderlist'); 59 | attachList(currentElement()); 60 | fn(); 61 | elementClose('x-reorderlist'); 62 | }; 63 | -------------------------------------------------------------------------------- /incremental-dom.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018 The Incremental DOM Authors. All Rights Reserved. 2 | /** @license SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | export {applyAttr, applyProp, attributes, createAttributeMap} from './src/attributes'; 5 | export {alignWithDOM, alwaysDiffAttributes, close, createPatchInner, createPatchOuter, currentElement, currentContext, currentPointer, open, patchInner as patch, patchInner, patchOuter, skip, skipNode, tryGetCurrentElement} from './src/core'; 6 | export {setKeyAttributeName} from './src/global'; 7 | export {clearCache,getKey, importNode, isDataInitialized} from './src/node_data'; 8 | export {notifications} from './src/notifications'; 9 | export {symbols} from './src/symbols'; 10 | export {applyAttrs, applyStatics, attr, elementClose, elementOpen, elementOpenEnd, elementOpenStart, elementVoid, key, text} from './src/virtual_elements'; 11 | export * from './src/types'; 12 | -------------------------------------------------------------------------------- /node_externs.js: -------------------------------------------------------------------------------- 1 | // Copyright 2016 The Incremental DOM Authors. All Rights Reserved. 2 | /** @license SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | /** @externs */ 5 | 6 | /** @type {?} */ 7 | var process; 8 | 9 | /** @type {?} */ 10 | process.env; 11 | 12 | /** @type {string} */ 13 | process.env.NODE_ENV; 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "incremental-dom", 3 | "version": "0.7.0", 4 | "description": "An in-place virtual DOM library", 5 | "exports": "dist/incremental-dom-esm.js", 6 | "main": "dist/incremental-dom-cjs.js", 7 | "author": "The Incremental DOM Authors", 8 | "license": "Apache-2.0", 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/google/incremental-dom.git" 12 | }, 13 | "files": [ 14 | "index.ts", 15 | "src", 16 | "dist" 17 | ], 18 | "scripts": { 19 | "test": "bazelisk test test:unit_tests", 20 | "build": "bazelisk build :dev", 21 | "dist": "npm run lint && npm run test && bazelisk build :npm", 22 | "publish": "npm run dist && bazelisk run :npm.publish", 23 | "update": "npm-check -u", 24 | "lint": "eslint src/**.ts" 25 | }, 26 | "devDependencies": { 27 | "@bazel/bazelisk": "^1.11.0", 28 | "@bazel/concatjs": "^4.6.0", 29 | "@bazel/ibazel": "^0.15.10", 30 | "@bazel/rollup": "^4.6.0", 31 | "@bazel/typescript": "^4.6.0", 32 | "@rollup/plugin-buble": "^0.21.3", 33 | "@types/mocha": "^5.0.0", 34 | "@types/sinon": "^4.3.0", 35 | "@types/sinon-chai": "^2.7.29", 36 | "@typescript-eslint/eslint-plugin": "^1.12.0", 37 | "@typescript-eslint/parser": "^1.12.0", 38 | "chai": "^4.0.2", 39 | "eslint": "^6.0.1", 40 | "eslint-config-prettier": "^6.0.0", 41 | "eslint-plugin-prettier": "^3.1.0", 42 | "karma": "^6.4.1", 43 | "karma-chrome-launcher": "^2.2.0", 44 | "karma-firefox-launcher": "^1.1.0", 45 | "karma-jasmine": "^4.0.1", 46 | "karma-requirejs": "^1.1.0", 47 | "karma-sourcemap-loader": "^0.3.8", 48 | "mocha": "^6.1.4", 49 | "npm-check": "^5.6.0", 50 | "prettier": "^1.18.2", 51 | "requirejs": "^2.3.6", 52 | "rollup": "^2.63.0", 53 | "sinon": "^4.0.0", 54 | "sinon-chai": "^2.9.0", 55 | "typescript": "~3.4.1", 56 | "uglify-js": "^3.6.0" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /perf/create-tests.js: -------------------------------------------------------------------------------- 1 | import {avg, filterOutliers} from './stats.js'; 2 | 3 | export function createTests(Renderer, tests, impls) { 4 | const { 5 | patch, 6 | elementOpen: eo, 7 | elementClose: ec, 8 | text: tx, 9 | } = IncrementalDOM; 10 | 11 | let currentTest; 12 | let currentImpl; 13 | let currentRenderer; 14 | let results; 15 | let running; 16 | 17 | const container = document.createElement('div'); 18 | const testContainer = document.createElement('div'); 19 | container.id = 'container'; 20 | 21 | document.body.appendChild(container); 22 | document.body.appendChild(testContainer); 23 | document.head.insertAdjacentHTML('beforeEnd', ` 24 | 34 | `); 35 | 36 | function update() { 37 | patch(container, render); 38 | } 39 | 40 | function setTestAndImpl(test, impl) { 41 | currentTest = test; 42 | currentImpl = impl; 43 | currentRenderer = new Renderer(testContainer, impl.obj); 44 | 45 | running = true; 46 | update(); 47 | run(); 48 | 49 | window.location.hash = impls.indexOf(impl) + ',' + tests.indexOf(test); 50 | } 51 | 52 | function setTest(test) { 53 | setTestAndImpl(test, currentImpl); 54 | } 55 | 56 | function setImpl(impl) { 57 | setTestAndImpl(currentTest, impl); 58 | } 59 | 60 | function render() { 61 | eo('div'); 62 | impls.forEach(function(impl) { 63 | eo('button', null, null, 64 | 'disabled', running || undefined, 65 | 'aria-selected', currentImpl === impl, 66 | 'onclick', function() { setImpl(impl); }); 67 | tx(impl.name); 68 | ec('button'); 69 | }); 70 | ec('div'); 71 | 72 | eo('div'); 73 | tests.forEach(function(test) { 74 | eo('button', null, null, 75 | 'disabled', running || undefined, 76 | 'aria-selected', currentTest === test, 77 | 'onclick', function() { setTest(test); }); 78 | tx(test.name); 79 | ec('button'); 80 | }); 81 | ec('div'); 82 | 83 | eo('div'); 84 | if (running) { 85 | tx('running'); 86 | } else { 87 | tx(results); 88 | } 89 | ec('div'); 90 | } 91 | 92 | function delay(time) { 93 | return new Promise(function(resolve) { setTimeout(resolve, time); }); 94 | } 95 | 96 | async function run() { 97 | await delay(100); 98 | const samples = await currentTest.fn(currentRenderer); 99 | const average = avg(filterOutliers(samples)); 100 | 101 | results = `time per iteration: ${average.toFixed(3)}ms`; 102 | running = false; 103 | update(); 104 | } 105 | 106 | const parts = window.location.hash.substring(1).split(','); 107 | const impl = Number(parts[0]) || 0; 108 | const test = Number(parts[1]) || 0; 109 | 110 | setTestAndImpl(tests[test], impls[impl]); 111 | } 112 | -------------------------------------------------------------------------------- /perf/creation-innerhtml.js: -------------------------------------------------------------------------------- 1 | let buf; 2 | 3 | function patch(el, fn, data) { 4 | buf = ''; 5 | fn(data); 6 | el.innerHTML = buf; 7 | } 8 | 9 | const EMPTY_ARRAY = []; 10 | 11 | const escapeHtml = (function() { 12 | const cache = {}; 13 | const textNode = document.createTextNode(''); 14 | const div = document.createElement('div'); 15 | div.appendChild(textNode); 16 | 17 | function escape(str) { 18 | textNode.data = str; 19 | return div.innerHTML; 20 | } 21 | 22 | return function(str) { 23 | return cache[str] || (cache[str] = escape(str)); 24 | } 25 | })(); 26 | 27 | function applyAttr(name, value) { 28 | if (value !== undefined) { 29 | buf += ' ' + name + '="' + value + '"'; 30 | } 31 | } 32 | 33 | function applyAttrEscaped(name, value) { 34 | if (typeof value === 'string') { 35 | applyAttr(name, escapeHtml(value)); 36 | } else { 37 | applyAttr(name, value); 38 | } 39 | } 40 | 41 | function elementOpen(tagName, key, statics) { 42 | let arr; 43 | let i; 44 | 45 | buf += '<' + tagName; 46 | 47 | arr = statics || EMPTY_ARRAY; 48 | for (i = 0; i < arr.length; i += 2) { 49 | applyAttrEscaped(arr[i], arr[i + 1]); 50 | } 51 | 52 | arr = arguments; 53 | for (i = 3; i < arr.length; i += 2) { 54 | applyAttrEscaped(arr[i], arr[i + 1]); 55 | } 56 | 57 | buf += '>'; 58 | } 59 | 60 | function elementClose(tagName) { 61 | buf += ''; 62 | } 63 | 64 | function elementVoid (tagName, key, statics) { 65 | elementOpen.apply(null, arguments); 66 | elementClose.apply(null, arguments); 67 | } 68 | 69 | function text(value) { 70 | let formatted = value; 71 | for (let i = 1; i < arguments.length; i += 1) { 72 | let formatter = arguments[i]; 73 | formatted = formatter(formatted); 74 | } 75 | 76 | buf += escapeHtml(formatted); 77 | } 78 | 79 | export const CreationInnerHtml = { 80 | patch, 81 | elementOpen, 82 | elementClose, 83 | elementVoid, 84 | text, 85 | }; 86 | -------------------------------------------------------------------------------- /perf/creation-js.js: -------------------------------------------------------------------------------- 1 | let currentParent; 2 | 3 | function patch(el, fn, data) { 4 | el.innerHTML = ''; 5 | currentParent = el; 6 | fn(data); 7 | } 8 | 9 | const EMPTY_ARRAY = []; 10 | 11 | function applyAttr(el, name, value) { 12 | if (value !== undefined) { 13 | el.setAttribute(name, value); 14 | } 15 | } 16 | 17 | function elementOpen(tagName, key, statics) { 18 | const el = document.createElement(tagName); 19 | let arr; 20 | let i; 21 | 22 | arr = statics || EMPTY_ARRAY; 23 | for (i = 0; i < arr.length; i += 2) { 24 | applyAttr(el, arr[i], arr[i + 1]); 25 | } 26 | 27 | arr = arguments; 28 | for (i = 3; i < arr.length; i += 2) { 29 | applyAttr(el, arr[i], arr[i + 1]); 30 | } 31 | 32 | currentParent.appendChild(el); 33 | currentParent = el; 34 | } 35 | 36 | function elementClose(tagName) { 37 | currentParent = currentParent.parentNode; 38 | } 39 | 40 | function elementVoid(tagName, key, statics) { 41 | elementOpen.apply(null, arguments); 42 | elementClose.apply(null, arguments); 43 | } 44 | 45 | function text(value) { 46 | let formatted = value; 47 | for (let i = 1; i < arguments.length; i += 1) { 48 | const formatter = arguments[i]; 49 | formatted = formatter(formatted); 50 | } 51 | 52 | const node = document.createTextNode(formatted); 53 | currentParent.appendChild(node); 54 | } 55 | 56 | export const CreationJs = { 57 | patch, 58 | elementOpen, 59 | elementClose, 60 | elementVoid, 61 | text, 62 | }; 63 | -------------------------------------------------------------------------------- /perf/list/add-start.js: -------------------------------------------------------------------------------- 1 | import {Samples} from '../samples.js'; 2 | import {createItems} from './setup.js'; 3 | import {afterRenderPromise} from '../util.js'; 4 | 5 | const ITEM_COUNT = 100; 6 | const ITERATION_COUNT = 100; 7 | const INSERT_COUNT = 1; 8 | const allItems = createItems(ITEM_COUNT + INSERT_COUNT); 9 | const itemsWithoutAddition = allItems.slice(INSERT_COUNT); 10 | 11 | export async function runAddStart(impl) { 12 | const samples = new Samples(ITERATION_COUNT); 13 | const selectedKeys = {}; 14 | 15 | async function reset() { 16 | impl.render({ 17 | items: itemsWithoutAddition, 18 | selectedKeys, 19 | }); 20 | // Make sure the layout time from putting the list back in the correct 21 | // state is not a part of the measurement below. 22 | await afterRenderPromise(); 23 | } 24 | 25 | async function update() { 26 | impl.render({ 27 | items: allItems, 28 | selectedKeys, 29 | }); 30 | // Wait until after the browser has rendered so that we get a better 31 | // picture of how much time we are actually spending, not just the JS time. 32 | await afterRenderPromise(); 33 | } 34 | 35 | async function pass() { 36 | await reset(); 37 | 38 | samples.timeStart(); 39 | await update(); 40 | samples.timeEnd(); 41 | } 42 | 43 | for (let i = 0; i < ITERATION_COUNT; i += 1) { 44 | await pass(); 45 | } 46 | 47 | return samples.data; 48 | }; 49 | -------------------------------------------------------------------------------- /perf/list/creation.js: -------------------------------------------------------------------------------- 1 | import {Samples} from '../samples.js'; 2 | import {createItems} from './setup.js'; 3 | 4 | const ITEM_COUNT = 100; 5 | const ITERATION_COUNT = 200; 6 | const items = createItems(ITEM_COUNT); 7 | 8 | export function runCreation(impl) { 9 | const samples = new Samples(ITERATION_COUNT); 10 | const selectedKeys = {}; 11 | 12 | function pass() { 13 | impl.clear(); 14 | 15 | samples.timeStart(); 16 | impl.render({ 17 | items: items, 18 | selectedKeys: selectedKeys 19 | }); 20 | samples.timeEnd(); 21 | } 22 | 23 | for (let i = 0; i < ITERATION_COUNT; i += 1) { 24 | pass(); 25 | } 26 | 27 | return Promise.resolve(samples.data); 28 | }; 29 | -------------------------------------------------------------------------------- /perf/list/css/style.css: -------------------------------------------------------------------------------- 1 | #list { 2 | display: flex; 3 | flex-direction: column; 4 | 5 | padding: 0; 6 | } 7 | 8 | .message { 9 | display: flex; 10 | align-items: center; 11 | 12 | padding: 5px; 13 | background-color: #f6f6f6; 14 | border: 1px solid rgba(0, 0, 0, 0.2); 15 | } 16 | 17 | .message[aria-selected="true"] { 18 | background-color: #4166f6; 19 | color: white; 20 | } 21 | 22 | .checkbox::before { 23 | content: "\2610"; 24 | margin: 4px; 25 | } 26 | 27 | .star { 28 | all: initial; 29 | } 30 | 31 | .star::before { 32 | content: "\2606"; 33 | margin: 4px; 34 | } 35 | 36 | .sender { 37 | flex: 1; 38 | margin-left: 10px; 39 | } 40 | 41 | .subject { 42 | flex: 2; 43 | } 44 | -------------------------------------------------------------------------------- /perf/list/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /perf/list/remove-start.js: -------------------------------------------------------------------------------- 1 | import {Samples} from '../samples.js'; 2 | import {createItems} from './setup.js'; 3 | import {afterRenderPromise} from '../util.js'; 4 | 5 | const ITEM_COUNT = 100; 6 | const ITERATION_COUNT = 100; 7 | const REMOVE_COUNT = 1; 8 | const allItems = createItems(ITEM_COUNT); 9 | const itemsWithRemoval = allItems.slice(REMOVE_COUNT); 10 | 11 | export async function runRemoveStart(impl) { 12 | const samples = new Samples(ITERATION_COUNT); 13 | const selectedKeys = {}; 14 | 15 | async function pass() { 16 | impl.render({ 17 | items: allItems, 18 | selectedKeys, 19 | }); 20 | // Make sure the layout time from putting the list back in the correct 21 | // state is not a part of the measurement below. 22 | await afterRenderPromise(); 23 | 24 | samples.timeStart(); 25 | impl.render({ 26 | items: itemsWithRemoval, 27 | selectedKeys, 28 | }); 29 | // Wait until after the browser has rendered so that we get a better 30 | // picture of how much time we are actually spending, not just the JS time. 31 | await afterRenderPromise(); 32 | samples.timeEnd(); 33 | } 34 | 35 | for (let i = 0; i < ITERATION_COUNT; i += 1) { 36 | await pass(); 37 | } 38 | 39 | return samples.data; 40 | }; 41 | -------------------------------------------------------------------------------- /perf/list/renderer.js: -------------------------------------------------------------------------------- 1 | const listStatics = [ 2 | 'id', 'list', 3 | 'role', 'list', 4 | ]; 5 | const itemStatics = [ 6 | 'class', 'message', 7 | 'role', 'listitem', 8 | 'tabindex', '-1', 9 | ]; 10 | const checkboxStatics = [ 11 | 'class', 'checkbox', 12 | 'role', 'checkbox', 13 | 'tabindex', '-1' 14 | ]; 15 | const starStatics = [ 16 | 'class', 'star' 17 | ]; 18 | const senderStatics = [ 19 | 'class', 'sender' 20 | ]; 21 | const subjectStatics = [ 22 | 'class', 'subject' 23 | ]; 24 | 25 | export function ListRenderer(container, lib) { 26 | const { 27 | patch, 28 | elementVoid, 29 | elementOpen, 30 | elementClose, 31 | text 32 | } = lib; 33 | 34 | function render(props) { 35 | const items = props.items; 36 | const selectedKeys = props.selectedKeys; 37 | 38 | elementOpen('ul', null, listStatics); 39 | 40 | for(let i = 0; i < items.length; i += 1) { 41 | const item = items[i]; 42 | const isSelected = selectedKeys[item.key]; 43 | 44 | elementOpen('li', item.key, itemStatics, 45 | 'aria-selected', isSelected); 46 | 47 | elementVoid('div', null, checkboxStatics, 48 | 'aria-checked', 'false'); 49 | 50 | elementVoid('button', null, starStatics, 51 | 'data-starred', item.starred, 52 | 'aria-label', item.starred ? 'Starred' : 'Not Starred'); 53 | 54 | elementOpen('span', null, senderStatics, 55 | 'title', item.sender); 56 | text(item.sender); 57 | elementClose('span'); 58 | 59 | elementOpen('a', null, subjectStatics, 60 | 'title', item.subject); 61 | text(item.subject); 62 | elementClose('a'); 63 | 64 | elementOpen('span'); 65 | text(item.date); 66 | elementClose('span'); 67 | 68 | elementClose('li'); 69 | } 70 | 71 | elementClose('ul'); 72 | } 73 | 74 | this.render = function(props) { 75 | patch(container, render, props) 76 | }; 77 | 78 | this.clear = function() { 79 | container.innerHTML = ''; 80 | }; 81 | } -------------------------------------------------------------------------------- /perf/list/selection-raf.js: -------------------------------------------------------------------------------- 1 | import {Samples} from '../samples.js'; 2 | import {createItems} from './setup.js'; 3 | 4 | const ITEM_COUNT = 200; 5 | const ITERATION_COUNT = 200; 6 | const items = createItems(ITEM_COUNT); 7 | 8 | export async function runSelectionRaf(impl) { 9 | const samples = new Samples(ITERATION_COUNT); 10 | const selectedKeys = {}; 11 | let counter = 0; 12 | let selectedIndex = 0; 13 | 14 | impl.clear(); 15 | impl.render({ 16 | items, 17 | selectedKeys 18 | }); 19 | 20 | function pass() { 21 | selectedKeys[items[selectedIndex].key] = false; 22 | selectedIndex = counter % ITEM_COUNT; 23 | selectedKeys[items[selectedIndex].key] = true; 24 | 25 | samples.timeStart(); 26 | impl.render({ 27 | items, 28 | selectedKeys 29 | }); 30 | samples.timeEnd(); 31 | 32 | counter += 1; 33 | return new Promise((resolve) => requestAnimationFrame(resolve)); 34 | } 35 | 36 | for (let i = 0; i < ITERATION_COUNT; i += 1) { 37 | await pass(); 38 | } 39 | 40 | return samples.data; 41 | } 42 | -------------------------------------------------------------------------------- /perf/list/selection.js: -------------------------------------------------------------------------------- 1 | import {Samples} from '../samples.js'; 2 | import {createItems} from './setup.js'; 3 | 4 | const ITEM_COUNT = 200; 5 | const ITERATION_COUNT = 400; 6 | const items = createItems(ITEM_COUNT); 7 | 8 | export function runSelection (impl) { 9 | const samples = new Samples(ITERATION_COUNT); 10 | const selectedKeys = {}; 11 | let counter = 0; 12 | let index = 0; 13 | 14 | impl.clear(); 15 | impl.render({ 16 | items, 17 | selectedKeys 18 | }); 19 | 20 | function pass() { 21 | selectedKeys[items[index].key] = false; 22 | index = counter % ITEM_COUNT; 23 | selectedKeys[items[index].key] = true; 24 | 25 | samples.timeStart(); 26 | impl.render({ 27 | items, 28 | selectedKeys 29 | }); 30 | samples.timeEnd(); 31 | 32 | counter++; 33 | } 34 | 35 | for (let i = 0; i < ITERATION_COUNT; i += 1) { 36 | pass(); 37 | } 38 | 39 | return samples.data; 40 | } -------------------------------------------------------------------------------- /perf/list/setup.js: -------------------------------------------------------------------------------- 1 | let uidGen = 1; 2 | 3 | function uid() { 4 | return uidGen++; 5 | } 6 | 7 | function randomChars() { 8 | return (Math.random() + 1).toString(36).substring(2); 9 | } 10 | 11 | function randomText(count) { 12 | return new Array(count).fill(0).map(randomChars).join(); 13 | } 14 | 15 | function createItem() { 16 | return { 17 | key: '' + uid(), 18 | sender: randomText(1), 19 | subject: randomText(4), 20 | date: 'July 4' 21 | }; 22 | } 23 | 24 | export function createItems(count) { 25 | return new Array(count).fill(0).map(createItem); 26 | } 27 | -------------------------------------------------------------------------------- /perf/mutation/creation.js: -------------------------------------------------------------------------------- 1 | import {Samples} from '../samples.js'; 2 | import {createItems} from './setup.js'; 3 | 4 | const ITEM_COUNT = 100; 5 | const ITERATION_COUNT = 200; 6 | const items = createItems(ITEM_COUNT); 7 | 8 | export function runCreation(impl) { 9 | const samples = new Samples(ITERATION_COUNT); 10 | 11 | function pass() { 12 | impl.clear(); 13 | 14 | samples.timeStart(); 15 | impl.render({ 16 | items, 17 | }); 18 | samples.timeEnd(); 19 | } 20 | 21 | for (var i = 0; i < ITERATION_COUNT; i += 1) { 22 | pass(); 23 | } 24 | 25 | return samples.data; 26 | }; 27 | 28 | -------------------------------------------------------------------------------- /perf/mutation/css/style.css: -------------------------------------------------------------------------------- 1 | #list { 2 | font-size: .9em; 3 | } 4 | 5 | .item-name { 6 | width: 100px; 7 | overflow: hidden; 8 | text-overflow: ellipsis; 9 | } 10 | 11 | .item-change { 12 | text-align: right; 13 | width: 160px; 14 | } 15 | 16 | .item-value { 17 | width: 55px; 18 | } 19 | 20 | .item-change { 21 | color: green; 22 | } 23 | 24 | .item-change[data-positive="false"] { 25 | color: red; 26 | } 27 | -------------------------------------------------------------------------------- /perf/mutation/high-raf.js: -------------------------------------------------------------------------------- 1 | import {Samples} from '../samples.js'; 2 | import {createItems, updateItems} from './setup.js'; 3 | 4 | const ITEM_COUNT = 200; 5 | const ITERATION_COUNT = 200; 6 | const ITEMS = createItems(ITEM_COUNT); 7 | 8 | export async function runHighRaf(impl) { 9 | const samples = new Samples(ITERATION_COUNT); 10 | const items = ITEMS.map(item => Object.assign({}, item)); 11 | 12 | impl.clear(); 13 | impl.render({ 14 | items, 15 | }); 16 | 17 | function pass() { 18 | updateItems(items); 19 | 20 | samples.timeStart(); 21 | impl.render({ 22 | items, 23 | }); 24 | samples.timeEnd(); 25 | 26 | return new Promise((resolve) => requestAnimationFrame(resolve)); 27 | } 28 | 29 | for (let i = 0; i < ITERATION_COUNT; i += 1) { 30 | await pass(); 31 | } 32 | 33 | return samples.data; 34 | } 35 | -------------------------------------------------------------------------------- /perf/mutation/high.js: -------------------------------------------------------------------------------- 1 | import {Samples} from '../samples.js'; 2 | import {createItems, updateItems} from './setup.js'; 3 | 4 | const ITEM_COUNT = 200; 5 | const ITERATION_COUNT = 400; 6 | const ITEMS = createItems(ITEM_COUNT); 7 | 8 | export function runHigh(impl) { 9 | const samples = new Samples(ITERATION_COUNT); 10 | const items = ITEMS.map((item) => Object.assign({}, item)); 11 | 12 | impl.clear(); 13 | impl.render({ 14 | items, 15 | }); 16 | 17 | function pass() { 18 | updateItems(items); 19 | 20 | samples.timeStart(); 21 | impl.render({ 22 | items, 23 | }); 24 | samples.timeEnd(); 25 | } 26 | 27 | for (var i = 0; i < ITERATION_COUNT; i += 1) { 28 | pass(); 29 | } 30 | 31 | return samples.data; 32 | }; 33 | -------------------------------------------------------------------------------- /perf/mutation/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /perf/mutation/renderer.js: -------------------------------------------------------------------------------- 1 | const listStatics = [ 2 | 'id', 'list', 3 | ]; 4 | const itemStatics = [ 5 | 'class', 'item', 6 | 'tabindex', '-1', 7 | ]; 8 | const nameStatics = [ 9 | 'class', 'item-name' 10 | ]; 11 | const valueStatics = [ 12 | 'class', 'item-value' 13 | ]; 14 | const changeStatics = [ 15 | 'class', 'item-change' 16 | ]; 17 | 18 | function wrapChange(value) { 19 | return ' (' + value + ')%'; 20 | } 21 | 22 | function toFixedTwo(value) { 23 | return value.toFixed(2); 24 | } 25 | 26 | function toPercent(value) { 27 | return value * 100; 28 | } 29 | 30 | export function MutationRenderer(container, lib) { 31 | const { 32 | patch, 33 | elementOpen, 34 | elementClose, 35 | text 36 | } = lib; 37 | 38 | function render(props) { 39 | const items = props.items; 40 | 41 | elementOpen('table', null, listStatics); 42 | 43 | for(let i = 0; i < items.length; i += 1) { 44 | const item = items[i]; 45 | const delta = item.value * item.change; 46 | 47 | elementOpen('tr', item.key, itemStatics); 48 | elementOpen('td', null, nameStatics); 49 | elementOpen('a', null, null, 50 | 'href', item.name); 51 | text(item.name); 52 | elementClose('a'); 53 | elementClose('td'); 54 | elementOpen('td', null, valueStatics); 55 | text(item.value, toFixedTwo); 56 | elementClose('td'); 57 | elementOpen('td', null, changeStatics, 58 | 'data-positive', item.change >= 0); 59 | text(delta, toFixedTwo); 60 | elementOpen('strong'); 61 | text(item.change, toPercent, toFixedTwo, wrapChange) 62 | elementClose('strong'); 63 | elementClose('td'); 64 | elementClose('tr'); 65 | } 66 | 67 | elementClose('table'); 68 | } 69 | 70 | this.render = function(props) { 71 | patch(container, render, props) 72 | }; 73 | 74 | this.clear = function() { 75 | container.innerHTML = ''; 76 | }; 77 | } -------------------------------------------------------------------------------- /perf/mutation/setup.js: -------------------------------------------------------------------------------- 1 | let uidGen = 1; 2 | 3 | function uid() { 4 | return uidGen++; 5 | } 6 | 7 | function randomPercent() { 8 | return (Math.random() - 0.5) * 0.3; 9 | } 10 | 11 | function randomValue() { 12 | return Math.random() * 700; 13 | } 14 | 15 | function randomChars() { 16 | return (Math.random() + 1).toString(36).substring(2, 10); 17 | } 18 | 19 | function randomText(count) { 20 | return new Array(count).fill(0).map(randomChars).join(); 21 | } 22 | 23 | function createItem() { 24 | return Object.freeze({ 25 | key: '' + uid(), 26 | name: randomText(1), 27 | value: randomValue(), 28 | change: randomPercent() 29 | }); 30 | } 31 | 32 | export function createItems(count) { 33 | return new Array(count).fill(0).map(createItem); 34 | } 35 | 36 | export function updateItems(items) { 37 | items.forEach((item, i) => { 38 | const change = randomPercent(); 39 | items[i].value *= (1 + change); 40 | items[i].change = change; 41 | }); 42 | } 43 | -------------------------------------------------------------------------------- /perf/samples.js: -------------------------------------------------------------------------------- 1 | export class Samples { 2 | constructor (count) { 3 | this.startTime = 0; 4 | 5 | this.data = new Array(count).fill(0); 6 | this.data.length = 0; 7 | } 8 | 9 | timeStart() { 10 | this.startTime = performance.now(); 11 | } 12 | 13 | timeEnd() { 14 | this.data.push(performance.now() - this.startTime); 15 | } 16 | } -------------------------------------------------------------------------------- /perf/stats.js: -------------------------------------------------------------------------------- 1 | export function filterOutliers(arr) { 2 | const values = [...arr]; 3 | values.sort(function(a, b) { return a - b }); 4 | 5 | const q1 = values[Math.floor((values.length / 4))]; 6 | const q3 = values[Math.ceil((values.length * (3 / 4)))]; 7 | const iqr = q3 - q1; 8 | 9 | const min = q1 - iqr*1.5; 10 | const max = q3 + iqr*1.5; 11 | 12 | return arr.filter(function(a) { return (min < a) && (a < max) }); 13 | } 14 | 15 | function sum(arr) { 16 | return arr.reduce(function(sum, val) { return sum + val }, 0); 17 | } 18 | 19 | export function avg(arr) { 20 | return sum(arr) / arr.length; 21 | } 22 | -------------------------------------------------------------------------------- /perf/util.js: -------------------------------------------------------------------------------- 1 | export async function afterRenderPromise() { 2 | await new Promise(resolve => { 3 | requestAnimationFrame(() => { 4 | setTimeout(resolve, 0); 5 | }); 6 | }); 7 | } -------------------------------------------------------------------------------- /release/BUILD: -------------------------------------------------------------------------------- 1 | package(default_visibility = ["//:__subpackages__"]) 2 | 3 | load("//:constants.bzl", "RELEASE_FILES") 4 | load("@npm//@bazel/typescript:index.bzl", "ts_library") 5 | 6 | genrule( 7 | name = "release_files", 8 | srcs = ["//src:release_files"], 9 | outs = RELEASE_FILES, 10 | cmd = ( 11 | "cp $(SRCS) $(@D)" 12 | ), 13 | ) 14 | 15 | ts_library( 16 | name = "release", 17 | srcs = [ 18 | "debug.ts", 19 | ":release_files", 20 | ], 21 | tsickle_typed = True, 22 | ) 23 | -------------------------------------------------------------------------------- /release/debug.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018 The Incremental DOM Authors. All Rights Reserved. 2 | /** @license SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | export const DEBUG = false; -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | output: { 3 | name: 'IncrementalDOM', 4 | banner: ` 5 | /** 6 | * @preserve 7 | * Copyright 2015 The Incremental DOM Authors. All Rights Reserved. 8 | * @license SPDX-License-Identifier: Apache-2.0 9 | */`, 10 | }, 11 | }; 12 | -------------------------------------------------------------------------------- /rules_nodejs_pr915.patch: -------------------------------------------------------------------------------- 1 | diff --git a/third_party/github.com/browserify/browserify/main.js b/third_party/github.com/browserify/browserify/main.js 2 | index 38c058f..7671ab3 100644 3 | --- a/third_party/github.com/browserify/browserify/main.js 4 | +++ b/third_party/github.com/browserify/browserify/main.js 5 | @@ -1,4 +1,4 @@ 6 | -var nextTick = require('process/browser.js').nextTick; 7 | +var nextTick = require('./browser1').nextTick; 8 | var apply = Function.prototype.apply; 9 | var slice = Array.prototype.slice; 10 | var immediateIds = {}; 11 | -------------------------------------------------------------------------------- /src/BUILD: -------------------------------------------------------------------------------- 1 | package(default_visibility = ["//:__subpackages__"]) 2 | 3 | load("@npm//@bazel/typescript:index.bzl", "ts_library") 4 | load("//:constants.bzl", "RELEASE_FILES") 5 | 6 | ts_library( 7 | name = "src", 8 | srcs = [ 9 | ":all_files", 10 | ], 11 | tsickle_typed = True, 12 | ) 13 | 14 | filegroup( 15 | name = "release_files", 16 | srcs = RELEASE_FILES, 17 | ) 18 | 19 | filegroup( 20 | name = "all_files", 21 | srcs = [ 22 | "debug.ts", 23 | ":release_files", 24 | ], 25 | ) 26 | -------------------------------------------------------------------------------- /src/assertions.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2015 The Incremental DOM Authors. All Rights Reserved. 2 | /** @license SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | import { DEBUG } from "./global"; 5 | import { NameOrCtorDef } from "./types"; 6 | 7 | /** 8 | * Keeps track whether or not we are in an attributes declaration (after 9 | * elementOpenStart, but before elementOpenEnd). 10 | */ 11 | let inAttributes = false; 12 | 13 | /** 14 | * Keeps track whether or not we are in an element that should not have its 15 | * children cleared. 16 | */ 17 | let inSkip = false; 18 | 19 | /** 20 | * Keeps track of whether or not we are in a patch. 21 | */ 22 | let inPatch = false; 23 | 24 | /** 25 | * Asserts that a value exists and is not null or undefined. goog.asserts 26 | * is not used in order to avoid dependencies on external code. 27 | * @param val The value to assert is truthy. 28 | * @returns The value. 29 | */ 30 | function assert(val: T | null | undefined): T { 31 | if (DEBUG && !val) { 32 | throw new Error("Expected value to be defined"); 33 | } 34 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 35 | return val!; 36 | } 37 | 38 | /** 39 | * Makes sure that there is a current patch context. 40 | * @param functionName The name of the caller, for the error message. 41 | */ 42 | function assertInPatch(functionName: string) { 43 | if (!inPatch) { 44 | throw new Error("Cannot call " + functionName + "() unless in patch."); 45 | } 46 | } 47 | 48 | /** 49 | * Makes sure that a patch closes every node that it opened. 50 | * @param openElement 51 | * @param root 52 | */ 53 | function assertNoUnclosedTags( 54 | openElement: Node | null, 55 | root: Node | DocumentFragment 56 | ) { 57 | if (openElement === root) { 58 | return; 59 | } 60 | 61 | let currentElement = openElement; 62 | const openTags: Array = []; 63 | while (currentElement && currentElement !== root) { 64 | openTags.push(currentElement.nodeName.toLowerCase()); 65 | currentElement = currentElement.parentNode; 66 | } 67 | 68 | throw new Error("One or more tags were not closed:\n" + openTags.join("\n")); 69 | } 70 | 71 | /** 72 | * Makes sure that node being outer patched has a parent node. 73 | * @param parent 74 | */ 75 | function assertPatchOuterHasParentNode(parent: Node | null) { 76 | if (!parent) { 77 | console.warn( 78 | "patchOuter requires the node have a parent if there is a key." 79 | ); 80 | } 81 | } 82 | 83 | /** 84 | * Makes sure that the caller is not where attributes are expected. 85 | * @param functionName The name of the caller, for the error message. 86 | */ 87 | function assertNotInAttributes(functionName: string) { 88 | if (inAttributes) { 89 | throw new Error( 90 | functionName + 91 | "() can not be called between " + 92 | "elementOpenStart() and elementOpenEnd()." 93 | ); 94 | } 95 | } 96 | 97 | /** 98 | * Makes sure that the caller is not inside an element that has declared skip. 99 | * @param functionName The name of the caller, for the error message. 100 | */ 101 | function assertNotInSkip(functionName: string) { 102 | if (inSkip) { 103 | throw new Error( 104 | functionName + 105 | "() may not be called inside an element " + 106 | "that has called skip()." 107 | ); 108 | } 109 | } 110 | 111 | /** 112 | * Makes sure that the caller is where attributes are expected. 113 | * @param functionName The name of the caller, for the error message. 114 | */ 115 | function assertInAttributes(functionName: string) { 116 | if (!inAttributes) { 117 | throw new Error( 118 | functionName + 119 | "() can only be called after calling " + 120 | "elementOpenStart()." 121 | ); 122 | } 123 | } 124 | 125 | /** 126 | * Makes sure the patch closes virtual attributes call 127 | */ 128 | function assertVirtualAttributesClosed() { 129 | if (inAttributes) { 130 | throw new Error( 131 | "elementOpenEnd() must be called after calling " + "elementOpenStart()." 132 | ); 133 | } 134 | } 135 | 136 | /** 137 | * Makes sure that tags are correctly nested. 138 | * @param currentNameOrCtor 139 | * @param nameOrCtor 140 | */ 141 | function assertCloseMatchesOpenTag( 142 | currentNameOrCtor: NameOrCtorDef, 143 | nameOrCtor: NameOrCtorDef 144 | ) { 145 | if (currentNameOrCtor !== nameOrCtor) { 146 | throw new Error( 147 | 'Received a call to close "' + 148 | nameOrCtor + 149 | '" but "' + 150 | currentNameOrCtor + 151 | '" was open.' 152 | ); 153 | } 154 | } 155 | 156 | /** 157 | * Makes sure that no children elements have been declared yet in the current 158 | * element. 159 | * @param functionName The name of the caller, for the error message. 160 | * @param previousNode 161 | */ 162 | function assertNoChildrenDeclaredYet( 163 | functionName: string, 164 | previousNode: Node | null 165 | ) { 166 | if (previousNode !== null) { 167 | throw new Error( 168 | functionName + 169 | "() must come before any child " + 170 | "declarations inside the current element." 171 | ); 172 | } 173 | } 174 | 175 | /** 176 | * Checks that a call to patchOuter actually patched the element. 177 | * @param maybeStartNode The value for the currentNode when the patch 178 | * started. 179 | * @param maybeCurrentNode The currentNode when the patch finished. 180 | * @param expectedNextNode The Node that is expected to follow the 181 | * currentNode after the patch; 182 | * @param expectedPrevNode The Node that is expected to preceed the 183 | * currentNode after the patch. 184 | */ 185 | function assertPatchElementNoExtras( 186 | maybeStartNode: Node | null, 187 | maybeCurrentNode: Node | null, 188 | expectedNextNode: Node | null, 189 | expectedPrevNode: Node | null 190 | ) { 191 | const startNode = assert(maybeStartNode); 192 | const currentNode = assert(maybeCurrentNode); 193 | const wasUpdated = 194 | currentNode.nextSibling === expectedNextNode && 195 | currentNode.previousSibling === expectedPrevNode; 196 | const wasChanged = 197 | currentNode.nextSibling === startNode.nextSibling && 198 | currentNode.previousSibling === expectedPrevNode; 199 | const wasRemoved = currentNode === startNode; 200 | 201 | if (!wasUpdated && !wasChanged && !wasRemoved) { 202 | throw new Error( 203 | "There must be exactly one top level call corresponding " + 204 | "to the patched element." 205 | ); 206 | } 207 | } 208 | 209 | /** 210 | * @param newContext The current patch context. 211 | */ 212 | function updatePatchContext(newContext: {} | null) { 213 | inPatch = newContext != null; 214 | } 215 | 216 | /** 217 | * Updates the state of being in an attribute declaration. 218 | * @param value Whether or not the patch is in an attribute declaration. 219 | * @return the previous value. 220 | */ 221 | function setInAttributes(value: boolean) { 222 | const previous = inAttributes; 223 | inAttributes = value; 224 | return previous; 225 | } 226 | 227 | /** 228 | * Updates the state of being in a skip element. 229 | * @param value Whether or not the patch is skipping the children of a 230 | * parent node. 231 | * @return the previous value. 232 | */ 233 | function setInSkip(value: boolean) { 234 | const previous = inSkip; 235 | inSkip = value; 236 | return previous; 237 | } 238 | 239 | export { 240 | assert, 241 | assertInPatch, 242 | assertNoUnclosedTags, 243 | assertNotInAttributes, 244 | assertInAttributes, 245 | assertCloseMatchesOpenTag, 246 | assertVirtualAttributesClosed, 247 | assertNoChildrenDeclaredYet, 248 | assertNotInSkip, 249 | assertPatchElementNoExtras, 250 | assertPatchOuterHasParentNode, 251 | setInAttributes, 252 | setInSkip, 253 | updatePatchContext 254 | }; 255 | -------------------------------------------------------------------------------- /src/attributes.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018 The Incremental DOM Authors. All Rights Reserved. 2 | /** @license SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | import { AttrMutatorConfig } from "./types"; 5 | import { assert } from "./assertions"; 6 | import { createMap, has } from "./util"; 7 | import { symbols } from "./symbols"; 8 | 9 | /** 10 | * @param name The name of the attribute. For example "tabindex" or 11 | * "xlink:href". 12 | * @returns The namespace to use for the attribute, or null if there is 13 | * no namespace. 14 | */ 15 | function getNamespace(name: string): string | null { 16 | if (name.lastIndexOf("xml:", 0) === 0) { 17 | return "http://www.w3.org/XML/1998/namespace"; 18 | } 19 | 20 | if (name.lastIndexOf("xlink:", 0) === 0) { 21 | return "http://www.w3.org/1999/xlink"; 22 | } 23 | 24 | return null; 25 | } 26 | 27 | /** 28 | * Applies an attribute or property to a given Element. If the value is null 29 | * or undefined, it is removed from the Element. Otherwise, the value is set 30 | * as an attribute. 31 | * @param el The element to apply the attribute to. 32 | * @param name The attribute's name. 33 | * @param value The attribute's value. 34 | */ 35 | function applyAttr(el: Element, name: string, value: unknown) { 36 | if (value == null) { 37 | el.removeAttribute(name); 38 | } else { 39 | const attrNS = getNamespace(name); 40 | if (attrNS) { 41 | el.setAttributeNS(attrNS, name, value as string); 42 | } else { 43 | el.setAttribute(name, value as string); 44 | } 45 | } 46 | } 47 | 48 | /** 49 | * Applies a property to a given Element. 50 | * @param el The element to apply the property to. 51 | * @param name The property's name. 52 | * @param value The property's value. 53 | */ 54 | function applyProp(el: Element, name: string, value: unknown) { 55 | (el as any)[name] = value; 56 | } 57 | 58 | /** 59 | * Applies a value to a style declaration. Supports CSS custom properties by 60 | * setting properties containing a dash using CSSStyleDeclaration.setProperty. 61 | * @param style A style declaration. 62 | * @param prop The property to apply. This can be either camelcase or dash 63 | * separated. For example: "backgroundColor" and "background-color" are both 64 | * supported. 65 | * @param value The value of the property. 66 | */ 67 | function setStyleValue( 68 | style: CSSStyleDeclaration, 69 | prop: string, 70 | value: string 71 | ) { 72 | if (prop.indexOf("-") >= 0) { 73 | style.setProperty(prop, value); 74 | } else { 75 | (style as any)[prop] = value; 76 | } 77 | } 78 | 79 | /** 80 | * Applies a style to an Element. No vendor prefix expansion is done for 81 | * property names/values. 82 | * @param el The Element to apply the style for. 83 | * @param name The attribute's name. 84 | * @param style The style to set. Either a string of css or an object 85 | * containing property-value pairs. 86 | */ 87 | function applyStyle( 88 | el: Element, 89 | name: string, 90 | style: string | { [k: string]: string } 91 | ) { 92 | // MathML elements inherit from Element, which does not have style. We cannot 93 | // do `instanceof HTMLElement` / `instanceof SVGElement`, since el can belong 94 | // to a different document, so just check that it has a style. 95 | assert("style" in el); 96 | const elStyle = (el).style; 97 | 98 | if (typeof style === "string") { 99 | elStyle.cssText = style; 100 | } else { 101 | elStyle.cssText = ""; 102 | 103 | for (const prop in style) { 104 | if (has(style, prop)) { 105 | setStyleValue(elStyle, prop, style[prop]); 106 | } 107 | } 108 | } 109 | } 110 | 111 | /** 112 | * Updates a single attribute on an Element. 113 | * @param el The Element to apply the attribute to. 114 | * @param name The attribute's name. 115 | * @param value The attribute's value. If the value is an object or 116 | * function it is set on the Element, otherwise, it is set as an HTML 117 | * attribute. 118 | */ 119 | function applyAttributeTyped(el: Element, name: string, value: unknown) { 120 | const type = typeof value; 121 | 122 | if (type === "object" || type === "function") { 123 | applyProp(el, name, value); 124 | } else { 125 | applyAttr(el, name, value); 126 | } 127 | } 128 | 129 | function createAttributeMap() { 130 | const attributes: AttrMutatorConfig = createMap() as AttrMutatorConfig; 131 | // Special generic mutator that's called for any attribute that does not 132 | // have a specific mutator. 133 | attributes[symbols.default] = applyAttributeTyped; 134 | 135 | attributes["style"] = applyStyle; 136 | return attributes; 137 | } 138 | 139 | /** 140 | * A publicly mutable object to provide custom mutators for attributes. 141 | * NB: The result of createMap() has to be recast since closure compiler 142 | * will just assume attributes is "any" otherwise and throws away 143 | * the type annotation set by tsickle. 144 | */ 145 | const attributes = createAttributeMap(); 146 | 147 | /** 148 | * Calls the appropriate attribute mutator for this attribute. 149 | * @param el The Element to apply the attribute to. 150 | * @param name The attribute's name. 151 | * @param value The attribute's value. If the value is an object or 152 | * function it is set on the Element, otherwise, it is set as an HTML 153 | * attribute. 154 | * @param attrs The attribute map of mutators. 155 | */ 156 | function updateAttribute( 157 | el: Element, 158 | name: string, 159 | value: unknown, 160 | attrs: AttrMutatorConfig 161 | ) { 162 | const mutator = attrs[name] || attrs[symbols.default]; 163 | mutator(el, name, value); 164 | } 165 | 166 | export { 167 | createAttributeMap, 168 | updateAttribute, 169 | applyProp, 170 | applyAttr, 171 | attributes 172 | }; 173 | -------------------------------------------------------------------------------- /src/changes.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018 The Incremental DOM Authors. All Rights Reserved. 2 | /** @license SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | import { truncateArray } from "./util"; 5 | 6 | const buffer: Array = []; 7 | 8 | let bufferStart = 0; 9 | 10 | /** 11 | * TODO(tomnguyen): This is a bit silly and really needs to be better typed. 12 | * @param fn A function to call. 13 | * @param a The first argument to the function. 14 | * @param b The second argument to the function. 15 | * @param c The third argument to the function. 16 | * @param d The fourth argument to the function 17 | */ 18 | function queueChange( 19 | fn: (a: A, b: B, c: C, d: D) => void, 20 | a: A, 21 | b: B, 22 | c: C, 23 | d: D 24 | ) { 25 | buffer.push(fn); 26 | buffer.push(a); 27 | buffer.push(b); 28 | buffer.push(c); 29 | buffer.push(d); 30 | } 31 | 32 | /** 33 | * Flushes the changes buffer, calling the functions for each change. 34 | */ 35 | function flush() { 36 | // A change may cause this function to be called re-entrantly. Keep track of 37 | // the portion of the buffer we are consuming. Updates the start pointer so 38 | // that the next call knows where to start from. 39 | const start = bufferStart; 40 | const end = buffer.length; 41 | 42 | bufferStart = end; 43 | 44 | for (let i = start; i < end; i += 5) { 45 | const fn = buffer[i] as (a: any, b: any, c: any, d: any) => undefined; 46 | fn(buffer[i + 1], buffer[i + 2], buffer[i + 3], buffer[i + 4]); 47 | } 48 | 49 | bufferStart = start; 50 | truncateArray(buffer, start); 51 | } 52 | 53 | export { queueChange, flush }; 54 | -------------------------------------------------------------------------------- /src/context.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018 The Incremental DOM Authors. All Rights Reserved. 2 | /** @license SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | import { notifications } from "./notifications"; 5 | 6 | /** 7 | * A context object keeps track of the state of a patch. 8 | */ 9 | class Context { 10 | private created: Array = []; 11 | private deleted: Array = []; 12 | public readonly node: Element | DocumentFragment; 13 | 14 | public constructor(node: Element | DocumentFragment) { 15 | this.node = node; 16 | } 17 | 18 | public markCreated(node: Node) { 19 | this.created.push(node); 20 | } 21 | 22 | public markDeleted(node: Node) { 23 | this.deleted.push(node); 24 | } 25 | 26 | /** 27 | * Notifies about nodes that were created during the patch operation. 28 | */ 29 | public notifyChanges() { 30 | if (notifications.nodesCreated && this.created.length > 0) { 31 | notifications.nodesCreated(this.created); 32 | } 33 | 34 | if (notifications.nodesDeleted && this.deleted.length > 0) { 35 | notifications.nodesDeleted(this.deleted); 36 | } 37 | } 38 | } 39 | 40 | export { Context }; 41 | -------------------------------------------------------------------------------- /src/debug.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018 The Incremental DOM Authors. All Rights Reserved. 2 | /** @license SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | export const DEBUG = true; 5 | -------------------------------------------------------------------------------- /src/diff.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018 The Incremental DOM Authors. All Rights Reserved. 2 | /** @license SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | import { AttrMutatorConfig } from "./types"; 5 | import { createMap, truncateArray } from "./util"; 6 | import { flush, queueChange } from "./changes"; 7 | 8 | /** 9 | * Used to keep track of the previous values when a 2-way diff is necessary. 10 | * This object is cleared out and reused. 11 | */ 12 | const prevValuesMap = createMap(); 13 | 14 | /** 15 | * Calculates the diff between previous and next values, calling the update 16 | * function when an item has changed value. If an item from the previous values 17 | * is not present in the the next values, the update function is called with a 18 | * value of `undefined`. 19 | * @param prev The previous values, alternating name, value pairs. 20 | * @param next The next values, alternating name, value pairs. 21 | * @param updateCtx The context for the updateFn. 22 | * @param updateFn A function to call when a value has changed. 23 | * @param attrs Attribute map for mutators 24 | * @param alwaysDiffAttributes Whether to diff attributes unconditionally 25 | */ 26 | function calculateDiff( 27 | prev: Array, 28 | next: Array, 29 | updateCtx: T, 30 | updateFn: ( 31 | ctx: T, 32 | x: string, 33 | y: {} | undefined, 34 | attrs: AttrMutatorConfig 35 | ) => void, 36 | attrs: AttrMutatorConfig, 37 | alwaysDiffAttributes: boolean = false 38 | ) { 39 | const isNew = !prev.length || alwaysDiffAttributes; 40 | let i = 0; 41 | 42 | for (; i < next.length; i += 2) { 43 | const name = next[i]; 44 | if (isNew) { 45 | prev[i] = name; 46 | } else if (prev[i] !== name) { 47 | break; 48 | } 49 | 50 | const value = next[i + 1]; 51 | if (isNew || prev[i + 1] !== value) { 52 | prev[i + 1] = value; 53 | queueChange(updateFn, updateCtx, name, value, attrs); 54 | } 55 | } 56 | 57 | // Items did not line up exactly as before, need to make sure old items are 58 | // removed. This should be a rare case. 59 | if (i < next.length || i < prev.length) { 60 | const startIndex = i; 61 | 62 | for (i = startIndex; i < prev.length; i += 2) { 63 | prevValuesMap[prev[i]] = prev[i + 1]; 64 | } 65 | 66 | for (i = startIndex; i < next.length; i += 2) { 67 | const name = next[i] as string; 68 | const value = next[i + 1]; 69 | 70 | if (prevValuesMap[name] !== value) { 71 | queueChange(updateFn, updateCtx, name, value, attrs); 72 | } 73 | 74 | prev[i] = name; 75 | prev[i + 1] = value; 76 | 77 | delete prevValuesMap[name]; 78 | } 79 | 80 | truncateArray(prev, next.length); 81 | 82 | for (const name in prevValuesMap) { 83 | queueChange(updateFn, updateCtx, name, undefined, attrs); 84 | delete prevValuesMap[name]; 85 | } 86 | } 87 | 88 | flush(); 89 | } 90 | 91 | export { calculateDiff }; 92 | -------------------------------------------------------------------------------- /src/dom_util.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018 The Incremental DOM Authors. All Rights Reserved. 2 | /** @license SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | import { assert } from "./assertions"; 5 | 6 | /** 7 | * Checks if the node is the root of a document. This is either a Document 8 | * or ShadowRoot. DocumentFragments are included for simplicity of the 9 | * implementation, though we only want to consider Documents or ShadowRoots. 10 | * @param node The node to check. 11 | * @return True if the node the root of a document, false otherwise. 12 | */ 13 | function isDocumentRoot(node: Node): node is Document | ShadowRoot { 14 | return node.nodeType === 11 || node.nodeType === 9; 15 | } 16 | 17 | /** 18 | * Checks if the node is an Element. This is faster than an instanceof check. 19 | * @param node The node to check. 20 | * @return Whether or not the node is an Element. 21 | */ 22 | function isElement(node: Node): node is Element { 23 | return node.nodeType === 1; 24 | } 25 | 26 | /** 27 | * Checks if the node is a text node. This is faster than an instanceof check. 28 | * @param node The node to check. 29 | * @return Whether or not the node is a Text. 30 | */ 31 | function isText(node: Node): node is Text { 32 | return node.nodeType === 3; 33 | } 34 | 35 | /** 36 | * @param node The node to start at, inclusive. 37 | * @param root The root ancestor to get until, exclusive. 38 | * @return The ancestry of DOM nodes. 39 | */ 40 | function getAncestry(node: Node, root: Node | null) { 41 | const ancestry: Array = []; 42 | let cur: Node | null = node; 43 | 44 | while (cur !== root) { 45 | const n: Node = assert(cur); 46 | ancestry.push(n); 47 | // If `node` is inside of a ShadowRoot, then it needs to pierce the 48 | // ShadowRoot boundary in order to reach `root`. 49 | cur = n.parentNode || (root ? (n as ShadowRoot).host : null); 50 | } 51 | 52 | return ancestry; 53 | } 54 | 55 | /** 56 | * @param this 57 | * @returns The root node of the DOM tree that contains this node. 58 | */ 59 | const getRootNode = 60 | (typeof Node !== "undefined" && (Node as any).prototype.getRootNode) || 61 | function(this: Node) { 62 | let cur: Node | null = this as Node; 63 | let prev = cur; 64 | 65 | while (cur) { 66 | prev = cur; 67 | cur = cur.parentNode; 68 | } 69 | 70 | return prev; 71 | }; 72 | 73 | /** 74 | * @param node The node to get the activeElement for. 75 | * @returns The activeElement in the Document or ShadowRoot 76 | * corresponding to node, if present. 77 | */ 78 | function getActiveElement(node: Node): Element | null { 79 | const root = getRootNode.call(node); 80 | return isDocumentRoot(root) ? root.activeElement : null; 81 | } 82 | 83 | /** 84 | * Gets the path of nodes that contain the focused node in the same document as 85 | * a reference node, up until the root. 86 | * @param node The reference node to get the activeElement for. 87 | * @param root The root to get the focused path until. 88 | * @returns The path of focused parents, if any exist. 89 | */ 90 | function getFocusedPath(node: Node, root: Node | null): Array { 91 | const activeElement = getActiveElement(node); 92 | 93 | if (!activeElement || !node.contains(activeElement)) { 94 | return []; 95 | } 96 | 97 | return getAncestry(activeElement, root); 98 | } 99 | 100 | /** 101 | * Like insertBefore, but instead of moving the desired node, it moves all the 102 | * other nodes after. 103 | * @param parentNode 104 | * @param node 105 | * @param referenceNode 106 | */ 107 | function moveBefore(parentNode: Node, node: Node, referenceNode: Node | null) { 108 | const insertReferenceNode = node.nextSibling; 109 | let cur = referenceNode; 110 | 111 | while (cur !== null && cur !== node) { 112 | const next = cur.nextSibling; 113 | parentNode.insertBefore(cur, insertReferenceNode); 114 | cur = next; 115 | } 116 | } 117 | 118 | export { isElement, isText, getFocusedPath, moveBefore }; 119 | -------------------------------------------------------------------------------- /src/global.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018 The Incremental DOM Authors. All Rights Reserved. 2 | /** @license SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | /** 5 | * The name of the HTML attribute that holds the element key 6 | * (e.g. `
`). The attribute value, if it exists, is then used 7 | * as the default key when importing an element. 8 | * If null, no attribute value is used as the default key. 9 | */ 10 | let keyAttributeName: string | null = "key"; 11 | 12 | function getKeyAttributeName() { 13 | return keyAttributeName; 14 | } 15 | 16 | function setKeyAttributeName(name: string | null) { 17 | keyAttributeName = name; 18 | } 19 | 20 | export { DEBUG } from "./debug"; 21 | export { getKeyAttributeName, setKeyAttributeName }; 22 | -------------------------------------------------------------------------------- /src/node_data.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018 The Incremental DOM Authors. All Rights Reserved. 2 | /** @license SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | import { Key, NameOrCtorDef } from "./types"; 5 | import { assert } from "./assertions"; 6 | import { createArray } from "./util"; 7 | import { isElement } from "./dom_util"; 8 | import { getKeyAttributeName } from "./global"; 9 | 10 | declare global { 11 | interface Node { 12 | __incrementalDOMData: NodeData | null; 13 | } 14 | } 15 | 16 | /** 17 | * Keeps track of information needed to perform diffs for a given DOM node. 18 | */ 19 | export class NodeData { 20 | /** 21 | * An array of attribute name/value pairs, used for quickly diffing the 22 | * incomming attributes to see if the DOM node's attributes need to be 23 | * updated. 24 | */ 25 | private _attrsArr: Array | null = null; 26 | 27 | /** 28 | * Whether or not the statics have been applied for the node yet. 29 | */ 30 | public staticsApplied = false; 31 | 32 | /** 33 | * The key used to identify this node, used to preserve DOM nodes when they 34 | * move within their parent. 35 | */ 36 | public readonly key: Key; 37 | 38 | /** 39 | * The previous text value, for Text nodes. 40 | */ 41 | public text: string | undefined; 42 | 43 | /** 44 | * The nodeName or contructor for the Node. 45 | */ 46 | public readonly nameOrCtor: NameOrCtorDef; 47 | 48 | public alwaysDiffAttributes = false; 49 | 50 | public constructor( 51 | nameOrCtor: NameOrCtorDef, 52 | key: Key, 53 | text: string | undefined 54 | ) { 55 | this.nameOrCtor = nameOrCtor; 56 | this.key = key; 57 | this.text = text; 58 | } 59 | 60 | public hasEmptyAttrsArr(): boolean { 61 | const attrs = this._attrsArr; 62 | return !attrs || !attrs.length; 63 | } 64 | 65 | public getAttrsArr(length: number): Array { 66 | return this._attrsArr || (this._attrsArr = createArray(length)); 67 | } 68 | } 69 | 70 | /** 71 | * Initializes a NodeData object for a Node. 72 | * @param node The Node to initialized data for. 73 | * @param nameOrCtor The NameOrCtorDef to use when diffing. 74 | * @param key The Key for the Node. 75 | * @param text The data of a Text node, if importing a Text node. 76 | * @returns A NodeData object with the existing attributes initialized. 77 | */ 78 | function initData( 79 | node: Node, 80 | nameOrCtor: NameOrCtorDef, 81 | key: Key, 82 | text?: string | undefined 83 | ): NodeData { 84 | const data = new NodeData(nameOrCtor, key, text); 85 | node["__incrementalDOMData"] = data; 86 | return data; 87 | } 88 | 89 | /** 90 | * @param node The node to check. 91 | * @returns True if the NodeData already exists, false otherwise. 92 | */ 93 | function isDataInitialized(node: Node): boolean { 94 | return Boolean(node["__incrementalDOMData"]); 95 | } 96 | 97 | /** 98 | * Records the element's attributes. 99 | * @param node The Element that may have attributes 100 | * @param data The Element's data 101 | */ 102 | function recordAttributes(node: Element, data: NodeData) { 103 | const attributes = node.attributes; 104 | const length = attributes.length; 105 | if (!length) { 106 | return; 107 | } 108 | 109 | const attrsArr = data.getAttrsArr(length); 110 | 111 | // Use a cached length. The attributes array is really a live NamedNodeMap, 112 | // which exists as a DOM "Host Object" (probably as C++ code). This makes the 113 | // usual constant length iteration very difficult to optimize in JITs. 114 | for (let i = 0, j = 0; i < length; i += 1, j += 2) { 115 | const attr = attributes[i]; 116 | const name = attr.name; 117 | const value = attr.value; 118 | 119 | attrsArr[j] = name; 120 | attrsArr[j + 1] = value; 121 | } 122 | } 123 | 124 | /** 125 | * Imports single node and its subtree, initializing caches, if it has not 126 | * already been imported. 127 | * @param node The node to import. 128 | * @param fallbackKey A key to use if importing and no key was specified. 129 | * Useful when not transmitting keys from serverside render and doing an 130 | * immediate no-op diff. 131 | * @returns The NodeData for the node. 132 | */ 133 | function importSingleNode(node: Node, fallbackKey?: Key): NodeData { 134 | if (node["__incrementalDOMData"]) { 135 | return node["__incrementalDOMData"]; 136 | } 137 | 138 | const nodeName = isElement(node) ? node.localName : node.nodeName; 139 | const keyAttrName = getKeyAttributeName(); 140 | const keyAttr = 141 | isElement(node) && keyAttrName != null 142 | ? node.getAttribute(keyAttrName) 143 | : null; 144 | const key = isElement(node) ? keyAttr || fallbackKey : null; 145 | const data = initData(node, nodeName, key); 146 | 147 | if (isElement(node)) { 148 | recordAttributes(node, data); 149 | } 150 | 151 | return data; 152 | } 153 | 154 | /** 155 | * Imports node and its subtree, initializing caches. 156 | * @param node The Node to import. 157 | */ 158 | function importNode(node: Node) { 159 | importSingleNode(node); 160 | 161 | for ( 162 | let child: Node | null = node.firstChild; 163 | child; 164 | child = child.nextSibling 165 | ) { 166 | importNode(child); 167 | } 168 | } 169 | 170 | /** 171 | * Retrieves the NodeData object for a Node, creating it if necessary. 172 | * @param node The node to get data for. 173 | * @param fallbackKey A key to use if importing and no key was specified. 174 | * Useful when not transmitting keys from serverside render and doing an 175 | * immediate no-op diff. 176 | * @returns The NodeData for the node. 177 | */ 178 | function getData(node: Node, fallbackKey?: Key) { 179 | return importSingleNode(node, fallbackKey); 180 | } 181 | 182 | /** 183 | * Gets the key for a Node. note that the Node should have been imported 184 | * by now. 185 | * @param node The node to check. 186 | * @returns The key used to create the node. 187 | */ 188 | function getKey(node: Node) { 189 | assert(node["__incrementalDOMData"]); 190 | return getData(node).key; 191 | } 192 | 193 | /** 194 | * Clears all caches from a node and all of its children. 195 | * @param node The Node to clear the cache for. 196 | */ 197 | function clearCache(node: Node) { 198 | node["__incrementalDOMData"] = null; 199 | 200 | for ( 201 | let child: Node | null = node.firstChild; 202 | child; 203 | child = child.nextSibling 204 | ) { 205 | clearCache(child); 206 | } 207 | } 208 | 209 | export { getData, getKey, initData, importNode, isDataInitialized, clearCache }; 210 | -------------------------------------------------------------------------------- /src/nodes.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018 The Incremental DOM Authors. All Rights Reserved. 2 | /** @license SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | import { getData, initData } from "./node_data"; 5 | import { Key, NameOrCtorDef } from "./types"; 6 | 7 | /** 8 | * Gets the namespace to create an element (of a given tag) in. 9 | * @param tag The tag to get the namespace for. 10 | * @param parent The current parent Node, if any. 11 | * @returns The namespace to use. 12 | */ 13 | function getNamespaceForTag(tag: string, parent: Node | null) { 14 | if (tag === "svg") { 15 | return "http://www.w3.org/2000/svg"; 16 | } 17 | 18 | if (tag === "math") { 19 | return "http://www.w3.org/1998/Math/MathML"; 20 | } 21 | 22 | if (parent == null) { 23 | return null; 24 | } 25 | 26 | if (getData(parent).nameOrCtor === "foreignObject") { 27 | return null; 28 | } 29 | 30 | // Since TypeScript 4.4 namespaceURI is only defined for Attr and Element 31 | // nodes. Checking for Element nodes here seems reasonable but breaks SVG 32 | // rendering in Chrome in certain cases. The cast to any should be removed 33 | // once we know why this happens. 34 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 35 | return (parent as any).namespaceURI; 36 | } 37 | 38 | /** 39 | * Creates an Element and initializes the NodeData. 40 | * @param doc The document with which to create the Element. 41 | * @param parent The parent of new Element. 42 | * @param nameOrCtor The tag or constructor for the Element. 43 | * @param key A key to identify the Element. 44 | * @returns The newly created Element. 45 | */ 46 | function createElement( 47 | doc: Document, 48 | parent: Node | null, 49 | nameOrCtor: NameOrCtorDef, 50 | key: Key 51 | ): Element { 52 | let el; 53 | 54 | if (typeof nameOrCtor === "function") { 55 | el = new nameOrCtor(); 56 | } else { 57 | const namespace = getNamespaceForTag(nameOrCtor, parent); 58 | 59 | if (namespace) { 60 | el = doc.createElementNS(namespace, nameOrCtor); 61 | } else { 62 | el = doc.createElement(nameOrCtor); 63 | } 64 | } 65 | 66 | initData(el, nameOrCtor, key); 67 | 68 | return el; 69 | } 70 | 71 | /** 72 | * Creates a Text Node. 73 | * @param doc The document with which to create the Element. 74 | * @returns The newly created Text. 75 | */ 76 | function createText(doc: Document): Text { 77 | const node = doc.createTextNode(""); 78 | initData(node, "#text", null); 79 | return node; 80 | } 81 | 82 | export { createElement, createText }; 83 | -------------------------------------------------------------------------------- /src/notifications.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018 The Incremental DOM Authors. All Rights Reserved. 2 | /** @license SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | export type NodeFunction = (n: Array) => void; 5 | 6 | export interface Notifications { 7 | /** 8 | * Called after patch has completed with any Nodes that have been created 9 | * and added to the DOM. 10 | */ 11 | nodesCreated: NodeFunction | null; 12 | /** 13 | * Called after patch has completed with any Nodes that have been removed 14 | * from the DOM. 15 | * Note it's an application's responsibility to handle any childNodes. 16 | */ 17 | nodesDeleted: NodeFunction | null; 18 | } 19 | 20 | export const notifications: Notifications = { 21 | nodesCreated: null, 22 | nodesDeleted: null 23 | }; 24 | -------------------------------------------------------------------------------- /src/symbols.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018 The Incremental DOM Authors. All Rights Reserved. 2 | /** @license SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | const symbols = { 5 | default: "__default" 6 | }; 7 | 8 | export { symbols }; 9 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018 The Incremental DOM Authors. All Rights Reserved. 2 | /** @license SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | export interface ElementConstructor { 5 | new (): Element; 6 | } 7 | 8 | export type AttrMutator = (a: Element, b: string, c: any) => void; 9 | 10 | export interface AttrMutatorConfig { 11 | [x: string]: AttrMutator; 12 | } 13 | 14 | export type NameOrCtorDef = string | ElementConstructor; 15 | 16 | export type Key = string | number | null | undefined; 17 | 18 | export type Statics = Array<{}> | null | undefined; 19 | 20 | export type PatchFunction = ( 21 | node: Element | DocumentFragment, 22 | template: (a: T | undefined) => void, 23 | data?: T | undefined 24 | ) => R; 25 | 26 | export type MatchFnDef = ( 27 | matchNode: Node, 28 | nameOrCtor: NameOrCtorDef, 29 | expectedNameOrCtor: NameOrCtorDef, 30 | key: Key, 31 | expectedKey: Key 32 | ) => boolean; 33 | 34 | export interface PatchConfig { 35 | matches?: MatchFnDef; 36 | } 37 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018 The Incremental DOM Authors. All Rights Reserved. 2 | /** @license SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | /** 5 | * A cached reference to the hasOwnProperty function. 6 | */ 7 | const hasOwnProperty = Object.prototype.hasOwnProperty; 8 | 9 | /** 10 | * A constructor function that will create blank objects. 11 | */ 12 | function Blank() {} 13 | 14 | Blank.prototype = Object.create(null); 15 | 16 | /** 17 | * Used to prevent property collisions between our "map" and its prototype. 18 | * @param map The map to check. 19 | * @param property The property to check. 20 | * @return Whether map has property. 21 | */ 22 | function has(map: object, property: string): boolean { 23 | return hasOwnProperty.call(map, property); 24 | } 25 | 26 | /** 27 | * Creates an map object without a prototype. 28 | * @returns An Object that can be used as a map. 29 | */ 30 | function createMap(): any { 31 | return new (Blank as any)(); 32 | } 33 | 34 | /** 35 | * Truncates an array, removing items up until length. 36 | * @param arr The array to truncate. 37 | * @param length The new length of the array. 38 | */ 39 | function truncateArray(arr: Array<{} | null | undefined>, length: number) { 40 | while (arr.length > length) { 41 | arr.pop(); 42 | } 43 | } 44 | 45 | /** 46 | * Creates an array for a desired initial size. Note that the array will still 47 | * be empty. 48 | * @param initialAllocationSize The initial size to allocate. 49 | * @returns An empty array, with an initial allocation for the desired size. 50 | */ 51 | function createArray(initialAllocationSize: number): Array { 52 | const arr = new Array(initialAllocationSize); 53 | truncateArray(arr, 0); 54 | return arr; 55 | } 56 | 57 | export { createArray, createMap, has, truncateArray }; 58 | -------------------------------------------------------------------------------- /test/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../.eslintrc", 3 | "globals": { 4 | "describe": true, 5 | "it": true, 6 | "beforeEach": true, 7 | "afterEach": true, 8 | "expect": true, 9 | "sinon": true 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /test/BUILD: -------------------------------------------------------------------------------- 1 | load("@npm//@bazel/concatjs:index.bzl", "karma_web_test") 2 | load("@npm//@bazel/typescript:index.bzl", "ts_library") 3 | 4 | ts_library( 5 | name = "test_lib", 6 | srcs = glob(["**/*.ts"]), 7 | deps = [ 8 | "//:dev", 9 | "//src", 10 | "@npm//@types", 11 | ], 12 | ) 13 | 14 | karma_web_test( 15 | name = "unit_tests", 16 | # chai and sinon-chai are not require()d in the code, we expect them to be globals 17 | # bootstrap their distro first on the page. 18 | bootstrap = [ 19 | "util/globals.js", 20 | "@npm//:node_modules/chai/chai.js", 21 | "@npm//:node_modules/sinon-chai/lib/sinon-chai.js", 22 | ], 23 | deps = [ 24 | # sinon is a dep because we have a require("sinon") in our code 25 | # we need the bazel-generated UMD bundle so there's a matching define("sinon") in the concatjs bundle 26 | "@npm//sinon:sinon__umd", 27 | ":test_lib", 28 | ], 29 | config_file = "karma.conf.js", 30 | ) 31 | -------------------------------------------------------------------------------- /test/functional/applyStatics_spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018 The Incremental DOM Authors. All Rights Reserved. 2 | /** @license SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | import {applyStatics, close, open, patch} from '../../index'; 5 | const {expect} = chai; 6 | 7 | /** 8 | * @param container 9 | */ 10 | function createMutationObserver(container: Element): MutationObserver { 11 | const mo = new MutationObserver(() => {}); 12 | mo.observe(container, { 13 | attributes: true, 14 | subtree: true, 15 | }); 16 | 17 | return mo; 18 | } 19 | 20 | describe('applyStatics', () => { 21 | let container: HTMLElement; 22 | 23 | beforeEach(() => { 24 | container = document.createElement('div'); 25 | document.body.appendChild(container); 26 | }); 27 | 28 | afterEach(() => { 29 | document.body.removeChild(container); 30 | }); 31 | 32 | it('should add attributes to the current element when created', () => { 33 | patch(container, () => { 34 | open('div'); 35 | applyStatics(['nameOne', 'valueOne', 'nameTwo', 'valueTwo']); 36 | close(); 37 | }); 38 | 39 | const firstChild = container.children[0]; 40 | expect(firstChild.attributes).to.have.length(2); 41 | expect(firstChild.getAttribute('nameOne')).to.equal('valueOne'); 42 | expect(firstChild.getAttribute('nameTwo')).to.equal('valueTwo'); 43 | }); 44 | 45 | it('should add attributes if called after a subtree', () => { 46 | patch(container, () => { 47 | open('div'); 48 | open('span'); 49 | close(); 50 | 51 | applyStatics(['nameOne', 'valueOne', 'nameTwo', 'valueTwo']); 52 | close(); 53 | }); 54 | 55 | const firstChild = container.children[0]; 56 | expect(firstChild.attributes).to.have.length(2); 57 | expect(firstChild.getAttribute('nameOne')).to.equal('valueOne'); 58 | expect(firstChild.getAttribute('nameTwo')).to.equal('valueTwo'); 59 | }); 60 | 61 | it('should not re-apply if the statics changed', () => { 62 | patch(container, () => { 63 | open('div'); 64 | applyStatics(['nameOne', 'valueOne', 'nameTwo', 'valueTwo']); 65 | close(); 66 | }); 67 | 68 | const mo = createMutationObserver(container); 69 | 70 | patch(container, () => { 71 | open('div'); 72 | applyStatics(['nameOne', 'valueOneNew', 'nameThree', 'valueThree']); 73 | close(); 74 | }); 75 | 76 | expect(mo.takeRecords()).to.be.empty; 77 | }); 78 | }); -------------------------------------------------------------------------------- /test/functional/buffered_attributes_spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018 The Incremental DOM Authors. All Rights Reserved. 2 | /** @license SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | import {applyAttrs, attr, close, open, patch} from '../../index'; 5 | const {expect} = chai; 6 | 7 | describe('buffered attributes', () => { 8 | let container: HTMLElement; 9 | 10 | beforeEach(() => { 11 | container = document.createElement('div'); 12 | document.body.appendChild(container); 13 | }); 14 | 15 | afterEach(() => { 16 | document.body.removeChild(container); 17 | }); 18 | 19 | it('should add attributes to the current element', () => { 20 | patch(container, () => { 21 | open('div'); 22 | attr('nameOne', 'valueOne'); 23 | attr('nameTwo', 'valueTwo'); 24 | applyAttrs(); 25 | close(); 26 | 27 | open('div'); 28 | attr('nameThree', 'valueThree'); 29 | applyAttrs(); 30 | close(); 31 | }); 32 | 33 | const firstChild = container.children[0]; 34 | const secondChild = container.children[1]; 35 | expect(firstChild.attributes).to.have.length(2); 36 | expect(firstChild.getAttribute('nameOne')).to.equal('valueOne'); 37 | expect(firstChild.getAttribute('nameTwo')).to.equal('valueTwo'); 38 | expect(secondChild.attributes).to.have.length(1); 39 | expect(secondChild.getAttribute('nameThree')).to.equal('valueThree'); 40 | }); 41 | 42 | it('should add attributes even when a subtree has been open/closed', () => { 43 | patch(container, () => { 44 | open('div'); 45 | open('span'); 46 | close(); 47 | 48 | attr('nameOne', 'valueOne'); 49 | attr('nameTwo', 'valueTwo'); 50 | applyAttrs(); 51 | close(); 52 | }); 53 | 54 | const firstChild = container.children[0]; 55 | expect(firstChild.attributes).to.have.length(2); 56 | expect(firstChild.getAttribute('nameOne')).to.equal('valueOne'); 57 | expect(firstChild.getAttribute('nameTwo')).to.equal('valueTwo'); 58 | }); 59 | 60 | it('should not be left over between patches', () => { 61 | patch(container, () => { 62 | attr('nameOne', 'valueOne'); 63 | attr('nameTwo', 'valueTwo'); 64 | }); 65 | 66 | patch(container, () => { 67 | open('div'); 68 | attr('nameThree', 'valueThree'); 69 | applyAttrs(); 70 | close(); 71 | }); 72 | 73 | const firstChild = container.children[0]; 74 | expect(firstChild.attributes).to.have.length(1); 75 | expect(firstChild.getAttribute('nameThree')).to.equal('valueThree'); 76 | }); 77 | 78 | it('should not carry over to nested patches', () => { 79 | const secondContainer = document.createElement('div'); 80 | 81 | patch(container, () => { 82 | attr('nameOne', 'valueOne'); 83 | attr('nameTwo', 'valueTwo'); 84 | 85 | patch(secondContainer, () => { 86 | open('div'); 87 | attr('nameThree', 'valueThree'); 88 | applyAttrs(); 89 | close(); 90 | }); 91 | }); 92 | 93 | const firstChild = secondContainer.children[0]; 94 | expect(firstChild.attributes).to.have.length(1); 95 | expect(firstChild.getAttribute('nameThree')).to.equal('valueThree'); 96 | }); 97 | 98 | it('should restore after nested patches', () => { 99 | const secondContainer = document.createElement('div'); 100 | 101 | patch(container, () => { 102 | attr('nameOne', 'valueOne'); 103 | attr('nameTwo', 'valueTwo'); 104 | 105 | patch(secondContainer, () => { 106 | open('div'); 107 | attr('nameThree', 'valueThree'); 108 | applyAttrs(); 109 | close(); 110 | }); 111 | 112 | open('div'); 113 | applyAttrs(); 114 | close(); 115 | }); 116 | 117 | const firstChild = container.children[0]; 118 | expect(firstChild.attributes).to.have.length(2); 119 | expect(firstChild.getAttribute('nameOne')).to.equal('valueOne'); 120 | expect(firstChild.getAttribute('nameTwo')).to.equal('valueTwo'); 121 | }); 122 | }); -------------------------------------------------------------------------------- /test/functional/conditional_rendering_spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018 The Incremental DOM Authors. All Rights Reserved. 2 | /** @license SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | import {elementClose, elementOpen, elementVoid, patch} from '../../index'; 5 | import {assertHTMLElement,} from '../util/dom'; 6 | const {expect} = chai; 7 | 8 | 9 | describe('conditional rendering', () => { 10 | let container: HTMLElement; 11 | 12 | beforeEach(() => { 13 | container = document.createElement('div'); 14 | document.body.appendChild(container); 15 | }); 16 | 17 | afterEach(() => { 18 | document.body.removeChild(container); 19 | }); 20 | 21 | describe('nodes', () => { 22 | function render(condition: boolean) { 23 | elementOpen('div', 'outer', ['id', 'outer']); 24 | elementVoid('div', 'one', ['id', 'one']); 25 | 26 | if (condition) { 27 | elementVoid('div', 'conditional-one', ['id', 'conditional-one']); 28 | elementVoid('div', 'conditional-two', ['id', 'conditional-two']); 29 | } 30 | 31 | elementVoid('span', 'two', ['id', 'two']); 32 | elementClose('div'); 33 | } 34 | 35 | it('should un-render when the condition becomes false', () => { 36 | patch(container, () => render(true)); 37 | patch(container, () => render(false)); 38 | const outer = container.childNodes[0]; 39 | 40 | expect(outer.childNodes).to.have.length(2); 41 | expect(assertHTMLElement(outer.childNodes[0]).id).to.equal('one'); 42 | expect(assertHTMLElement(outer.childNodes[0]).tagName).to.equal('DIV'); 43 | expect(assertHTMLElement(outer.childNodes[1]).id).to.equal('two'); 44 | expect(assertHTMLElement(outer.childNodes[1]).tagName).to.equal('SPAN'); 45 | }); 46 | 47 | it('should not move non-keyed nodes', () => { 48 | function render(condition: boolean) { 49 | if (condition) { 50 | elementVoid('div'); 51 | } 52 | 53 | elementVoid('span'); 54 | elementVoid('div'); 55 | } 56 | 57 | patch(container, () => render(false)); 58 | const secondDiv = container.lastChild; 59 | patch(container, () => render(true)); 60 | const firstChild = container.firstChild; 61 | const lastChild = container.lastChild; 62 | 63 | expect(container.childNodes).to.have.length(3); 64 | expect(firstChild).to.not.equal(secondDiv); 65 | expect(lastChild).to.equal(secondDiv); 66 | }); 67 | 68 | 69 | it('should render when the condition becomes true', () => { 70 | patch(container, () => render(false)); 71 | patch(container, () => render(true)); 72 | const outer = container.childNodes[0]; 73 | 74 | expect(outer.childNodes).to.have.length(4); 75 | expect(assertHTMLElement(outer.childNodes[0]).id).to.equal('one'); 76 | expect(assertHTMLElement(outer.childNodes[0]).tagName).to.equal('DIV'); 77 | expect(assertHTMLElement(outer.childNodes[1]).id) 78 | .to.equal('conditional-one'); 79 | expect(assertHTMLElement(outer.childNodes[1]).tagName).to.equal('DIV'); 80 | expect(assertHTMLElement(outer.childNodes[2]).id) 81 | .to.equal('conditional-two'); 82 | expect(assertHTMLElement(outer.childNodes[2]).tagName).to.equal('DIV'); 83 | expect(assertHTMLElement(outer.childNodes[3]).id).to.equal('two'); 84 | expect(assertHTMLElement(outer.childNodes[3]).tagName).to.equal('SPAN'); 85 | }); 86 | }); 87 | 88 | describe('with only conditional childNodes', () => { 89 | function render(condition: boolean) { 90 | elementOpen('div', 'outer', ['id', 'outer']); 91 | 92 | if (condition) { 93 | elementVoid('div', 'conditional-one', ['id', 'conditional-one']); 94 | elementVoid('div', 'conditional-two', ['id', 'conditional-two']); 95 | } 96 | 97 | elementClose('div'); 98 | } 99 | 100 | it('should not leave any remaning nodes', () => { 101 | patch(container, () => render(true)); 102 | patch(container, () => render(false)); 103 | const outer = container.childNodes[0]; 104 | 105 | expect(outer.childNodes).to.have.length(0); 106 | }); 107 | }); 108 | 109 | describe('nodes', () => { 110 | function render(condition: boolean) { 111 | elementOpen('div', null, null, 'id', 'outer'); 112 | elementVoid('div', null, null, 'id', 'one'); 113 | 114 | if (condition) { 115 | elementOpen( 116 | 'span', null, null, 'id', 'conditional-one', 'data-foo', 'foo'); 117 | elementVoid('span'); 118 | elementClose('span'); 119 | } 120 | 121 | elementVoid('span', null, null, 'id', 'two'); 122 | elementClose('div'); 123 | } 124 | 125 | it('should strip children when a conflicting node is re-used', () => { 126 | patch(container, () => render(true)); 127 | patch(container, () => render(false)); 128 | const outer = container.childNodes[0]; 129 | 130 | expect(outer.childNodes).to.have.length(2); 131 | expect(assertHTMLElement(outer.childNodes[0]).id).to.equal('one'); 132 | expect(assertHTMLElement(outer.childNodes[0]).tagName).to.equal('DIV'); 133 | expect(assertHTMLElement(outer.childNodes[1]).id).to.equal('two'); 134 | expect(assertHTMLElement(outer.childNodes[1]).tagName).to.equal('SPAN'); 135 | expect(assertHTMLElement(outer.childNodes[1]).children.length) 136 | .to.equal(0); 137 | }); 138 | 139 | it('should strip attributes when a conflicting node is re-used', () => { 140 | patch(container, () => render(true)); 141 | patch(container, () => render(false)); 142 | const outer = container.childNodes[0]; 143 | 144 | expect(assertHTMLElement(outer.childNodes[1]).getAttribute('data-foo')) 145 | .to.be.null; 146 | }); 147 | }); 148 | }); 149 | -------------------------------------------------------------------------------- /test/functional/constructors_spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018 The Incremental DOM Authors. All Rights Reserved. 2 | /** @license SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | import * as Sinon from 'sinon'; 5 | 6 | import {close, open, patch} from '../../index'; 7 | import {assertHTMLElement,} from '../util/dom'; 8 | const {expect} = chai; 9 | 10 | declare global { 11 | interface Window { 12 | // tslint:disable-next-line:no-any 13 | MyElementDefine: any; 14 | // tslint:disable-next-line:no-any 15 | MyElementRegister: any; 16 | } 17 | } 18 | 19 | describe('Element constructors', () => { 20 | const MyElementRegister = window.MyElementRegister; 21 | const MyElementDefine = window.MyElementDefine; 22 | let container: HTMLElement; 23 | const sandbox = Sinon.sandbox.create(); 24 | 25 | beforeEach(() => { 26 | container = document.createElement('div'); 27 | document.body.appendChild(container); 28 | }); 29 | 30 | afterEach(() => { 31 | sandbox.restore(); 32 | document.body.removeChild(container); 33 | }); 34 | 35 | describe('element creation', () => { 36 | if (MyElementRegister) { 37 | it('should render when created with document.registerElement', () => { 38 | patch(container, () => { 39 | open(MyElementRegister); 40 | close(); 41 | }); 42 | 43 | const el = assertHTMLElement(container.firstChild); 44 | expect(el.localName).to.equal('my-element-register'); 45 | expect(el.constructor).to.equal(MyElementRegister); 46 | }); 47 | } 48 | 49 | if (MyElementDefine) { 50 | it('should render when created with customElements.define', () => { 51 | patch(container, () => { 52 | open(MyElementDefine); 53 | close(); 54 | }); 55 | 56 | const el = container.firstChild as HTMLElement; 57 | expect(el.localName).to.equal('my-element-define'); 58 | expect(el.constructor).to.equal(MyElementDefine); 59 | }); 60 | } 61 | }); 62 | 63 | describe('updates', () => { 64 | if (MyElementRegister) { 65 | it('should re-use elements with the same constructor', () => { 66 | function render() { 67 | open(MyElementRegister); 68 | close(); 69 | } 70 | 71 | patch(container, render); 72 | const el = container.firstChild; 73 | patch(container, render); 74 | 75 | expect(container.firstChild).to.equal(el); 76 | }); 77 | } 78 | }); 79 | }); 80 | 81 | -------------------------------------------------------------------------------- /test/functional/currentElement_spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018 The Incremental DOM Authors. All Rights Reserved. 2 | /** @license SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | import {currentElement, elementClose, elementOpen, elementOpenEnd, elementOpenStart, elementVoid, patch} from '../../index'; 5 | const {expect} = chai; 6 | 7 | 8 | describe('currentElement', () => { 9 | let container: HTMLElement; 10 | let el: Element|null; 11 | 12 | beforeEach(() => { 13 | container = document.createElement('div'); 14 | document.body.appendChild(container); 15 | }); 16 | 17 | afterEach(() => { 18 | document.body.removeChild(container); 19 | el = null; 20 | }); 21 | 22 | it('should return the element from elementOpen', () => { 23 | patch(container, () => { 24 | elementOpen('div'); 25 | el = currentElement(); 26 | elementClose('div'); 27 | }); 28 | expect(el).to.equal(container.childNodes[0]); 29 | }); 30 | 31 | it('should return the element from elementOpenEnd', () => { 32 | patch(container, () => { 33 | elementOpenStart('div'); 34 | elementOpenEnd(); 35 | el = currentElement(); 36 | elementClose('div'); 37 | }); 38 | 39 | expect(el).to.equal(container.childNodes[0]); 40 | }); 41 | 42 | it('should return the parent after elementClose', () => { 43 | patch(container, () => { 44 | elementOpen('div'); 45 | elementClose('div'); 46 | el = currentElement(); 47 | }); 48 | 49 | expect(el).to.equal(container); 50 | }); 51 | 52 | it('should return the parent after elementVoid', () => { 53 | patch(container, () => { 54 | elementVoid('div'); 55 | el = currentElement(); 56 | }); 57 | 58 | expect(el).to.equal(container); 59 | }); 60 | 61 | it('should throw an error if not patching', () => { 62 | expect(currentElement).to.throw('Cannot call currentElement() unless in patch'); 63 | }); 64 | 65 | it('should throw an error if inside virtual attributes element', () => { 66 | expect(() => { 67 | patch(container, () => { 68 | elementOpenStart('div'); 69 | el = currentElement(); 70 | elementOpenEnd(); 71 | elementClose('div'); 72 | }); 73 | }).to.throw('currentElement() can not be called between elementOpenStart() and elementOpenEnd().'); 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /test/functional/currentPointer_spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018 The Incremental DOM Authors. All Rights Reserved. 2 | /** @license SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | import {currentPointer, elementClose, elementOpenEnd, elementOpenStart, elementVoid, patch} from '../../index'; 5 | import {assertHTMLElement,} from '../util/dom'; 6 | const {expect} = chai; 7 | 8 | describe('currentPointer', () => { 9 | let container: HTMLElement; 10 | let firstChild: HTMLElement; 11 | let lastChild: HTMLElement; 12 | 13 | beforeEach(() => { 14 | container = document.createElement('div'); 15 | container.innerHTML = `
`; 16 | 17 | firstChild = assertHTMLElement(container.firstChild); 18 | lastChild = assertHTMLElement(container.lastChild); 19 | 20 | document.body.appendChild(container); 21 | }); 22 | 23 | afterEach(() => { 24 | document.body.removeChild(container); 25 | }); 26 | 27 | it('should return null if no children', () => { 28 | container.innerHTML = ''; 29 | 30 | let el; 31 | 32 | patch(container, () => { 33 | el = currentPointer(); 34 | }); 35 | 36 | expect(el).to.equal(null); 37 | }); 38 | 39 | it('should return the first child when an element was just opened', () => { 40 | let el; 41 | 42 | patch(container, () => { 43 | el = currentPointer(); 44 | }); 45 | 46 | expect(el).to.equal(firstChild); 47 | }); 48 | 49 | it('should return the next node to evaluate', () => { 50 | let el; 51 | 52 | patch(container, () => { 53 | elementVoid('div'); 54 | el = currentPointer(); 55 | }); 56 | 57 | expect(el).to.equal(lastChild); 58 | }); 59 | 60 | it('should return null if past the end', () => { 61 | let el; 62 | 63 | patch(container, () => { 64 | elementVoid('div'); 65 | elementVoid('span'); 66 | el = currentPointer(); 67 | }); 68 | 69 | expect(el).to.equal(null); 70 | }); 71 | 72 | it('should throw an error if not patching', () => { 73 | expect(currentPointer).to.throw('Cannot call currentPointer() unless in patch'); 74 | }); 75 | 76 | it('should throw an error if inside virtual attributes element', () => { 77 | expect(() => { 78 | patch(container, () => { 79 | elementOpenStart('div'); 80 | currentPointer(); 81 | elementOpenEnd(); 82 | elementClose('div'); 83 | }); 84 | }).to.throw('currentPointer() can not be called between elementOpenStart() and elementOpenEnd().'); 85 | }); 86 | }); 87 | -------------------------------------------------------------------------------- /test/functional/element_creation_spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018 The Incremental DOM Authors. All Rights Reserved. 2 | /** @license SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | import * as Sinon from 'sinon'; 5 | 6 | import {elementClose, elementOpen, elementOpenEnd, elementOpenStart, elementVoid, patch} from '../../index'; 7 | import {assertElement, assertHTMLElement,} from '../util/dom'; 8 | 9 | const {expect} = chai; 10 | 11 | 12 | describe('element creation', () => { 13 | let container: HTMLElement; 14 | const sandbox = Sinon.sandbox.create(); 15 | 16 | beforeEach(() => { 17 | container = document.createElement('div'); 18 | document.body.appendChild(container); 19 | }); 20 | 21 | afterEach(() => { 22 | sandbox.restore(); 23 | document.body.removeChild(container); 24 | }); 25 | 26 | describe('when creating a single node', () => { 27 | let el: HTMLElement; 28 | 29 | beforeEach(() => { 30 | patch(container, () => { 31 | elementVoid('div', 'key', ['id', 'someId', 'class', 'someClass', 'data-custom', 'custom'], 32 | 'data-foo', 'Hello', 33 | 'data-bar', 'World'); 34 | }); 35 | 36 | el = assertHTMLElement(container.childNodes[0]); 37 | }); 38 | 39 | it('should render with the specified tag', () => { 40 | expect(el.tagName).to.equal('DIV'); 41 | }); 42 | 43 | it('should render with static attributes', () => { 44 | expect(el.id).to.equal('someId'); 45 | expect(el.className).to.equal('someClass'); 46 | expect(el.getAttribute('data-custom')).to.equal('custom'); 47 | }); 48 | 49 | it('should render with dynamic attributes', () => { 50 | expect(el.getAttribute('data-foo')).to.equal('Hello'); 51 | expect(el.getAttribute('data-bar')).to.equal('World'); 52 | }); 53 | 54 | describe('should return DOM node', () => { 55 | beforeEach(() => { 56 | patch(container, () => {}); 57 | }); 58 | 59 | it('from elementOpen', () => { 60 | patch(container, () => { 61 | el = elementOpen('div'); 62 | elementClose('div'); 63 | }); 64 | 65 | expect(el).to.equal(container.childNodes[0]); 66 | }); 67 | 68 | it('from elementClose', () => { 69 | patch(container, () => { 70 | elementOpen('div'); 71 | el = assertHTMLElement(elementClose('div')); 72 | }); 73 | 74 | expect(el).to.equal(container.childNodes[0]); 75 | }); 76 | 77 | it('from elementVoid', () => { 78 | patch(container, () => { 79 | el = assertHTMLElement(elementVoid('div')); 80 | }); 81 | 82 | expect(el).to.equal(container.childNodes[0]); 83 | }); 84 | 85 | it('from elementOpenEnd', () => { 86 | patch(container, () => { 87 | elementOpenStart('div'); 88 | el = elementOpenEnd(); 89 | elementClose('div'); 90 | }); 91 | 92 | expect(el).to.equal(container.childNodes[0]); 93 | }); 94 | }); 95 | }); 96 | 97 | it('should allow creation without static attributes', () => { 98 | patch(container, () => { 99 | elementVoid('div', null, null, 100 | 'id', 'test'); 101 | }); 102 | const el = assertHTMLElement(container.childNodes[0]); 103 | expect(el.id).to.equal('test'); 104 | }); 105 | 106 | describe('for HTML elements', () => { 107 | it('should use the XHTML namespace', () => { 108 | patch(container, () => { 109 | elementVoid('div'); 110 | }); 111 | 112 | const el = container.childNodes[0]; 113 | expect(el.namespaceURI).to.equal('http://www.w3.org/1999/xhtml'); 114 | }); 115 | 116 | it('should use createElement if no namespace has been specified', () => { 117 | const doc = container.ownerDocument!; 118 | const div = doc.createElement('div'); 119 | let el: HTMLElement; 120 | sandbox.stub(doc, 'createElement').returns(div); 121 | 122 | patch(container, () => { 123 | elementOpen('svg'); 124 | elementOpen('foreignObject'); 125 | el = assertHTMLElement(elementVoid('div')); 126 | expect(el.namespaceURI).to.equal('http://www.w3.org/1999/xhtml'); 127 | elementClose('foreignObject'); 128 | elementClose('svg'); 129 | }); 130 | 131 | expect(doc.createElement).to.have.been.calledOnce; 132 | }); 133 | }); 134 | 135 | describe('for svg elements', () => { 136 | beforeEach(() => { 137 | patch(container, () => { 138 | elementOpen('svg'); 139 | elementOpen('g'); 140 | elementVoid('circle'); 141 | elementClose('g'); 142 | elementOpen('foreignObject'); 143 | elementVoid('p'); 144 | elementClose('foreignObject'); 145 | elementVoid('path'); 146 | elementClose('svg'); 147 | }); 148 | }); 149 | 150 | it('should create svgs in the svg namespace', () => { 151 | const el = assertElement(container.querySelector('svg')); 152 | expect(el.namespaceURI).to.equal('http://www.w3.org/2000/svg'); 153 | }); 154 | 155 | it('should create descendants of svgs in the svg namespace', () => { 156 | const el = assertElement(container.querySelector('circle')); 157 | expect(el.namespaceURI).to.equal('http://www.w3.org/2000/svg'); 158 | }); 159 | 160 | it('should have the svg namespace for foreignObjects', () => { 161 | const el = assertElement(container.querySelector('svg')!.childNodes[1]); 162 | expect(el.namespaceURI).to.equal('http://www.w3.org/2000/svg'); 163 | }); 164 | 165 | it('should revert to the xhtml namespace when encounering a foreignObject', 166 | () => { 167 | const el = assertElement(container.querySelector('p')); 168 | expect(el.namespaceURI).to.equal('http://www.w3.org/1999/xhtml'); 169 | }); 170 | 171 | it('should reset to the previous namespace after exiting a forignObject', 172 | () => { 173 | const el = assertElement(container.querySelector('path')); 174 | expect(el.namespaceURI).to.equal('http://www.w3.org/2000/svg'); 175 | }); 176 | 177 | it('should create children in the svg namespace when patching an svg', 178 | () => { 179 | const svg = assertElement(container.querySelector('svg')); 180 | patch(svg, () => { 181 | elementVoid('rect'); 182 | }); 183 | 184 | const el = assertElement(svg.querySelector('rect')); 185 | expect(el.namespaceURI).to.equal('http://www.w3.org/2000/svg'); 186 | }); 187 | }); 188 | 189 | describe('for math elements', () => { 190 | beforeEach(() => { 191 | patch(container, () => { 192 | elementOpen('math'); 193 | elementOpen('semantics'); 194 | elementOpen('mrow'); 195 | elementVoid('mo'); 196 | elementClose('mrow'); 197 | elementClose('semantics'); 198 | elementClose('math'); 199 | elementVoid('p'); 200 | }); 201 | }); 202 | 203 | it('should create equations in the MathML namespace', () => { 204 | const el = assertElement(container.querySelector('math')); 205 | expect(el.namespaceURI).to.equal('http://www.w3.org/1998/Math/MathML'); 206 | }); 207 | 208 | it('should create descendants of math in the MathML namespace', () => { 209 | const el = assertElement(container.querySelector('mo')); 210 | expect(el.namespaceURI).to.equal('http://www.w3.org/1998/Math/MathML'); 211 | }); 212 | 213 | it('should reset to the previous namespace after exiting math', 214 | () => { 215 | const el = assertElement(container.querySelector('p')); 216 | expect(el.namespaceURI).to.equal('http://www.w3.org/1999/xhtml'); 217 | }); 218 | 219 | it('should create children in the MathML namespace when patching an equation', 220 | () => { 221 | const mrow = assertElement(container.querySelector('mrow')); 222 | patch(mrow, () => { 223 | elementVoid('mi'); 224 | }); 225 | 226 | const el = assertElement(mrow.querySelector('mi')); 227 | expect(el.namespaceURI).to.equal('http://www.w3.org/1998/Math/MathML'); 228 | }); 229 | }); 230 | }); 231 | -------------------------------------------------------------------------------- /test/functional/errors_spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018 The Incremental DOM Authors. All Rights Reserved. 2 | /** @license SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | import * as Sinon from 'sinon'; 5 | 6 | import {attr, currentElement, elementClose, elementOpen, elementOpenEnd, elementOpenStart, elementVoid, patch} from '../../index'; 7 | const {expect} = chai; 8 | 9 | describe('Errors while rendering', () => { 10 | let container: HTMLElement; 11 | const sandbox = Sinon.sandbox.create(); 12 | 13 | function patchWithUnclosedElement() { 14 | expect(() => { 15 | patch(currentElement(), () => { 16 | elementOpen('div'); 17 | throw new Error('Never closed element!'); 18 | }); 19 | }).to.throw('Never closed element!'); 20 | } 21 | 22 | beforeEach(() => { 23 | container = document.createElement('div'); 24 | document.body.appendChild(container); 25 | }); 26 | 27 | afterEach(() => { 28 | sandbox.restore(); 29 | document.body.removeChild(container); 30 | }); 31 | 32 | it('should continue patching', () => { 33 | patch(container, () => { 34 | elementOpen('div'); 35 | 36 | elementOpen('div'); 37 | patchWithUnclosedElement(); 38 | elementClose('div'); 39 | 40 | elementVoid('div'); 41 | elementClose('div'); 42 | }); 43 | 44 | const el = container.children[0]; 45 | expect(el.children).to.have.length(2); 46 | }); 47 | 48 | it('should restore state while an element is open', () => { 49 | patch(container, () => { 50 | elementOpen('div'); 51 | 52 | elementOpen('div'); 53 | patchWithUnclosedElement(); 54 | elementClose('div'); 55 | 56 | elementVoid('span'); 57 | elementClose('div'); 58 | }); 59 | 60 | const el = container.children[0]; 61 | expect(el.children).to.have.length(2); 62 | expect(el.children[1].tagName).to.equal('SPAN'); 63 | }); 64 | 65 | it('should restore state while calling elementOpenStart', () => { 66 | patch(container, () => { 67 | const otherContainer = document.createElement('div'); 68 | 69 | elementOpenStart('div'); 70 | attr('parrentAttrOne', 'parrentAttrValOne'); 71 | 72 | expect(() => { 73 | patch(otherContainer, () => { 74 | elementOpenStart('div'); 75 | attr('childAttr', 'childAttrVal'); 76 | throw new Error(); 77 | }); 78 | }).to.throw(); 79 | 80 | attr('parrentAttrTwo', 'parrentAttrValTwo'); 81 | elementOpenEnd(); 82 | 83 | elementClose('div'); 84 | }); 85 | 86 | const attributes = container.children[0].attributes; 87 | expect(attributes).to.have.length(2); 88 | expect(attributes['parrentAttrOne'].value).to.equal('parrentAttrValOne'); 89 | expect(attributes['parrentAttrTwo'].value).to.equal('parrentAttrValTwo'); 90 | }); 91 | 92 | it('should render any partial elements', () => { 93 | expect(() => { 94 | patch(container, () => { 95 | elementVoid('div'); 96 | elementOpen('div'); 97 | elementOpen('div'); 98 | throw new Error(); 99 | }); 100 | }).to.throw(); 101 | 102 | const el = container.children[1]; 103 | expect(container.children).to.have.length(2); 104 | expect(el.children).to.have.length(1); 105 | }); 106 | }); 107 | -------------------------------------------------------------------------------- /test/functional/formatters_spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018 The Incremental DOM Authors. All Rights Reserved. 2 | /** @license SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | import * as Sinon from 'sinon'; 5 | 6 | import {patch, text} from '../../index'; 7 | const {expect} = chai; 8 | 9 | describe('formatters', () => { 10 | let container: HTMLElement; 11 | 12 | beforeEach(() => { 13 | container = document.createElement('div'); 14 | document.body.appendChild(container); 15 | }); 16 | 17 | afterEach(() => { 18 | document.body.removeChild(container); 19 | }); 20 | 21 | describe('for newly created Text nodes', () => { 22 | function sliceOne(str: {}): string { 23 | return ('' + str).slice(1); 24 | } 25 | 26 | function prefixQuote(str: {}): string { 27 | return '\'' + str; 28 | } 29 | 30 | it('should render with the specified formatted value', () => { 31 | patch(container, () => { 32 | text('hello world!', sliceOne, prefixQuote); 33 | }); 34 | const node = container.childNodes[0]; 35 | 36 | expect(node.textContent).to.equal('\'ello world!'); 37 | }); 38 | }); 39 | 40 | describe('for updated Text nodes', () => { 41 | let stub: Sinon.SinonStub; 42 | 43 | function render(value: string) { 44 | text(value, stub); 45 | } 46 | 47 | beforeEach(() => { 48 | stub = Sinon.stub(); 49 | stub.onFirstCall().returns('stubValueOne'); 50 | stub.onSecondCall().returns('stubValueTwo'); 51 | }); 52 | 53 | it('should not call the formatter for unchanged values', () => { 54 | patch(container, () => render('hello')); 55 | patch(container, () => render('hello')); 56 | const node = container.childNodes[0]; 57 | 58 | expect(node.textContent).to.equal('stubValueOne'); 59 | expect(stub).to.have.been.calledOnce; 60 | }); 61 | 62 | it('should call the formatter when the value changes', () => { 63 | patch(container, () => render('hello')); 64 | patch(container, () => render('world')); 65 | const node = container.childNodes[0]; 66 | 67 | expect(node.textContent).to.equal('stubValueTwo'); 68 | expect(stub).to.have.been.calledTwice; 69 | }); 70 | 71 | it('should call the formatter even if the initial value matches', () => { 72 | container.textContent = 'test'; 73 | 74 | patch(container, () => { 75 | text('test', s => s + 'Z'); 76 | }); 77 | 78 | expect(container.textContent).to.equal('testZ'); 79 | }); 80 | }); 81 | 82 | it('should not leak the arguments object', () => { 83 | const stub = Sinon.stub().returns('value'); 84 | patch(container, () => text('value', stub)); 85 | 86 | expect(stub).to.not.have.been.calledOn(['value', stub]); 87 | }); 88 | }); 89 | -------------------------------------------------------------------------------- /test/functional/hooks_spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018 The Incremental DOM Authors. All Rights Reserved. 2 | /** @license SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | import * as Sinon from 'sinon'; 5 | 6 | import {attributes, elementVoid, notifications, patch, symbols, text} from '../../index'; 7 | const {expect} = chai; 8 | 9 | describe('library hooks', () => { 10 | const sandbox = Sinon.sandbox.create(); 11 | let container: HTMLElement; 12 | let allSpy: Sinon.SinonSpy; 13 | let stub: Sinon.SinonStub; 14 | 15 | beforeEach(() => { 16 | container = document.createElement('div'); 17 | document.body.appendChild(container); 18 | }); 19 | 20 | afterEach(() => { 21 | document.body.removeChild(container); 22 | sandbox.restore(); 23 | }); 24 | 25 | describe('for deciding how attributes are set', () => { 26 | // tslint:disable-next-line:no-any 27 | function render(dynamicValue: any) { 28 | elementVoid( 29 | 'div', 'key', ['staticName', 'staticValue'], 'dynamicName', 30 | dynamicValue); 31 | } 32 | 33 | function stubOut(mutator: string) { 34 | stub = sandbox.stub(); 35 | attributes[mutator] = stub; 36 | } 37 | 38 | beforeEach(() => { 39 | allSpy = sandbox.spy(attributes, symbols.default); 40 | }); 41 | 42 | afterEach(() => { 43 | for (const mutator in attributes) { 44 | if (mutator !== symbols.default && mutator !== 'style') { 45 | delete attributes[mutator]; 46 | } 47 | } 48 | }); 49 | 50 | 51 | describe('for static attributes', () => { 52 | it('should call specific setter', () => { 53 | stubOut('staticName'); 54 | 55 | patch(container, render, 'dynamicValue'); 56 | const el = container.childNodes[0]; 57 | 58 | expect(stub).to.have.been.calledOnce; 59 | expect(stub).to.have.been.calledWith(el, 'staticName', 'staticValue'); 60 | }); 61 | 62 | it('should call generic setter', () => { 63 | patch(container, render, 'dynamicValue'); 64 | const el = container.childNodes[0]; 65 | 66 | expect(allSpy).to.have.been.calledWith(el, 'staticName', 'staticValue'); 67 | }); 68 | 69 | it('should prioritize specific setter over generic', () => { 70 | stubOut('staticName'); 71 | 72 | patch(container, render, 'dynamicValue'); 73 | const el = container.childNodes[0]; 74 | 75 | expect(stub).to.have.been.calledOnce; 76 | expect(allSpy).to.have.been.calledOnce; 77 | expect(allSpy).to.have.been.calledWith( 78 | el, 'dynamicName', 'dynamicValue'); 79 | }); 80 | }); 81 | 82 | describe('for specific dynamic attributes', () => { 83 | beforeEach(() => { 84 | stubOut('dynamicName'); 85 | }); 86 | 87 | it('should be called for dynamic attributes', () => { 88 | patch(container, render, 'dynamicValue'); 89 | const el = container.childNodes[0]; 90 | 91 | expect(stub).to.have.been.calledOnce; 92 | expect(stub).to.have.been.calledWith(el, 'dynamicName', 'dynamicValue'); 93 | }); 94 | 95 | it('should be called on attribute update', () => { 96 | patch(container, render, 'dynamicValueOne'); 97 | patch(container, render, 'dynamicValueTwo'); 98 | const el = container.childNodes[0]; 99 | 100 | expect(stub).to.have.been.calledTwice; 101 | expect(stub).to.have.been.calledWith( 102 | el, 'dynamicName', 'dynamicValueTwo'); 103 | }); 104 | 105 | it('should only be called when attributes change', () => { 106 | patch(container, render, 'dynamicValue'); 107 | patch(container, render, 'dynamicValue'); 108 | const el = container.childNodes[0]; 109 | 110 | expect(stub).to.have.been.calledOnce; 111 | expect(stub).to.have.been.calledWith(el, 'dynamicName', 'dynamicValue'); 112 | }); 113 | 114 | it('should prioritize specific setter over generic', () => { 115 | patch(container, render, 'dynamicValue'); 116 | const el = container.childNodes[0]; 117 | 118 | expect(stub).to.have.been.calledOnce; 119 | expect(allSpy).to.have.been.calledOnce; 120 | expect(allSpy).to.have.been.calledWith(el, 'staticName', 'staticValue'); 121 | }); 122 | }); 123 | 124 | describe('for generic dynamic attributes', () => { 125 | it('should be called for dynamic attributes', () => { 126 | patch(container, render, 'dynamicValue'); 127 | const el = container.childNodes[0]; 128 | 129 | expect(allSpy).to.have.been.calledWith( 130 | el, 'dynamicName', 'dynamicValue'); 131 | }); 132 | 133 | it('should be called on attribute update', () => { 134 | patch(container, render, 'dynamicValueOne'); 135 | patch(container, render, 'dynamicValueTwo'); 136 | const el = container.childNodes[0]; 137 | 138 | expect(allSpy).to.have.been.calledWith( 139 | el, 'dynamicName', 'dynamicValueTwo'); 140 | }); 141 | 142 | it('should only be called when attributes change', () => { 143 | patch(container, render, 'dynamicValue'); 144 | patch(container, render, 'dynamicValue'); 145 | const el = container.childNodes[0]; 146 | 147 | expect(allSpy).to.have.been.calledTwice; 148 | expect(allSpy).to.have.been.calledWith(el, 'staticName', 'staticValue'); 149 | expect(allSpy).to.have.been.calledWith( 150 | el, 'dynamicName', 'dynamicValue'); 151 | }); 152 | }); 153 | }); 154 | 155 | describe('for being notified when nodes are created and added to DOM', () => { 156 | beforeEach(() => { 157 | notifications.nodesCreated = sandbox.spy((nodes) => { 158 | expect(nodes[0].parentNode).to.not.equal(null); 159 | }); 160 | }); 161 | 162 | afterEach(() => { 163 | notifications.nodesCreated = null; 164 | }); 165 | 166 | it('should be called for elements', () => { 167 | patch(container, function render() { 168 | elementVoid('div', 'key', ['staticName', 'staticValue']); 169 | }); 170 | const el = container.childNodes[0]; 171 | 172 | expect(notifications.nodesCreated).to.have.been.calledOnce; 173 | expect(notifications.nodesCreated).calledWith([el]); 174 | }); 175 | 176 | it('should be called for text', () => { 177 | patch(container, function render() { 178 | text('hello'); 179 | }); 180 | const el = container.childNodes[0]; 181 | 182 | expect(notifications.nodesCreated).to.have.been.calledOnce; 183 | expect(notifications.nodesCreated).calledWith([el]); 184 | }); 185 | }); 186 | 187 | describe('for being notified when nodes are deleted from the DOM', () => { 188 | function render(withTxt) { 189 | if (withTxt) { 190 | text('hello'); 191 | } else { 192 | elementVoid('div', 'key2', ['staticName', 'staticValue']); 193 | } 194 | } 195 | 196 | function empty() {} 197 | 198 | beforeEach(() => { 199 | notifications.nodesDeleted = sandbox.spy((nodes) => { 200 | expect(nodes[0].parentNode).to.equal(null); 201 | }); 202 | }); 203 | 204 | afterEach(() => { 205 | notifications.nodesDeleted = null; 206 | }); 207 | 208 | it('should be called for detached element', () => { 209 | patch(container, render, false); 210 | const el = container.childNodes[0]; 211 | patch(container, empty); 212 | 213 | expect(notifications.nodesDeleted).to.have.been.calledOnce; 214 | expect(notifications.nodesDeleted).calledWith([el]); 215 | }); 216 | 217 | it('should be called for detached text', () => { 218 | patch(container, render, true); 219 | const el = container.childNodes[0]; 220 | patch(container, empty); 221 | 222 | expect(notifications.nodesDeleted).to.have.been.calledOnce; 223 | expect(notifications.nodesDeleted).calledWith([el]); 224 | }); 225 | 226 | it('should be called for replaced element', () => { 227 | patch(container, render, false); 228 | const el = container.childNodes[0]; 229 | patch(container, render, true); 230 | 231 | expect(notifications.nodesDeleted).to.have.been.calledOnce; 232 | expect(notifications.nodesDeleted).calledWith([el]); 233 | }); 234 | 235 | it('should be called for removed text', () => { 236 | patch(container, render, true); 237 | const el = container.childNodes[0]; 238 | patch(container, render, false); 239 | 240 | expect(notifications.nodesDeleted).to.have.been.calledOnce; 241 | expect(notifications.nodesDeleted).calledWith([el]); 242 | }); 243 | }); 244 | 245 | describe('for not being notified when Elements are reordered', () => { 246 | function render(first) { 247 | if (first) { 248 | elementVoid('div', 'keyA', ['staticName', 'staticValue']); 249 | } 250 | elementVoid('div', 'keyB'); 251 | if (!first) { 252 | elementVoid('div', 'keyA', ['staticName', 'staticValue']); 253 | } 254 | } 255 | 256 | beforeEach(() => { 257 | notifications.nodesDeleted = sandbox.spy(); 258 | }); 259 | 260 | afterEach(() => { 261 | notifications.nodesDeleted = null; 262 | }); 263 | 264 | it('should not call the nodesDeleted callback', () => { 265 | patch(container, render, true); 266 | patch(container, render, false); 267 | expect(notifications.nodesDeleted).to.have.callCount(0); 268 | }); 269 | }); 270 | }); 271 | -------------------------------------------------------------------------------- /test/functional/importing_element_spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018 The Incremental DOM Authors. All Rights Reserved. 2 | /** @license SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | // taze: mocha from //third_party/javascript/typings/mocha 5 | // taze: chai from //third_party/javascript/typings/chai 6 | 7 | import * as Sinon from 'sinon'; 8 | 9 | import {elementClose, elementOpen, elementVoid, importNode, patch} from '../../index'; 10 | const {expect} = chai; 11 | 12 | describe('importing element', () => { 13 | let container: HTMLElement; 14 | const sandbox = Sinon.sandbox.create(); 15 | 16 | beforeEach(() => { 17 | container = document.createElement('div'); 18 | document.body.appendChild(container); 19 | }); 20 | 21 | afterEach(() => { 22 | sandbox.restore(); 23 | document.body.removeChild(container); 24 | }); 25 | 26 | describe('in HTML', () => { 27 | it('handles normal nodeName capitalization', () => { 28 | container.innerHTML = '
'; 29 | importNode(container); 30 | 31 | const el = container.firstChild; 32 | patch(container, () => elementVoid('div')); 33 | expect(container.firstChild).to.equal(el); 34 | }); 35 | 36 | it('handles odd nodeName capitalization', () => { 37 | container.innerHTML = '
'; 38 | importNode(container); 39 | 40 | const el = container.firstChild; 41 | patch(container, () => elementVoid('div')); 42 | expect(container.firstChild).to.equal(el); 43 | }); 44 | }); 45 | 46 | describe('in SVG', () => { 47 | it('handles normal nodeName capitalization', () => { 48 | container.innerHTML = ''; 49 | importNode(container); 50 | 51 | const foreign = container.firstChild!.firstChild; 52 | patch(container, () => { 53 | elementOpen('svg'); 54 | elementVoid('foreignObject'); 55 | elementClose('svg'); 56 | }); 57 | expect(container.firstChild!.firstChild).to.equal(foreign); 58 | }); 59 | 60 | it('handles odd nodeName capitalization', () => { 61 | container.innerHTML = ''; 62 | importNode(container); 63 | 64 | const foreign = container.firstChild!.firstChild; 65 | patch(container, () => { 66 | elementOpen('svg'); 67 | elementVoid('foreignObject'); 68 | elementClose('svg'); 69 | }); 70 | expect(container.firstChild!.firstChild).to.equal(foreign); 71 | }); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /test/functional/patchConfig_matches_spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018 The Incremental DOM Authors. All Rights Reserved. 2 | /** @license SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | // taze: mocha from //third_party/javascript/typings/mocha 5 | // taze: chai from //third_party/javascript/typings/chai 6 | 7 | import { 8 | elementVoid, 9 | createPatchInner, 10 | createPatchOuter, 11 | patchInner, 12 | } from '../../index'; 13 | import { text, elementOpen, elementClose } from '../../src/virtual_elements'; 14 | const {expect} = chai; 15 | 16 | describe('patchConfig\'s matches option', () => { 17 | let container: HTMLElement; 18 | 19 | beforeEach(() => { 20 | container = document.createElement('div'); 21 | document.body.appendChild(container); 22 | }); 23 | 24 | afterEach(() => { 25 | document.body.removeChild(container); 26 | }); 27 | 28 | describe('default matches', () => { 29 | describe('for createPatchInner', () => { 30 | it('should match with the same key and node name', () => { 31 | const patch = createPatchInner(); 32 | 33 | patch(container, () => elementVoid('div', 'foo')); 34 | const postPatchOneChild = container.firstChild; 35 | patch(container, () => elementVoid('div', 'foo')); 36 | 37 | expect(container.childNodes).to.have.length(1); 38 | expect(container.firstChild).to.equal(postPatchOneChild); 39 | }); 40 | 41 | it('should match for text nodes', () => { 42 | const patch = createPatchInner(); 43 | 44 | patch(container, () => text('foo')); 45 | const postPatchOneChild = container.firstChild; 46 | patch(container, () => text('foo')); 47 | 48 | expect(container.childNodes).to.have.length(1); 49 | expect(container.firstChild).to.equal(postPatchOneChild); 50 | }); 51 | 52 | it('should not match with different tags', () => { 53 | const patch = createPatchInner(); 54 | 55 | patch(container, () => elementVoid('div', 'foo')); 56 | const postPatchOneChild = container.firstChild; 57 | patch(container, () => elementVoid('span', 'foo')); 58 | 59 | expect(container.childNodes).to.have.length(1); 60 | expect(container.firstChild).to.not.equal(postPatchOneChild); 61 | }); 62 | 63 | it('should not match with different keys', () => { 64 | const patch = createPatchInner(); 65 | 66 | patch(container, () => elementVoid('div', 'foo')); 67 | const postPatchOneChild = container.firstChild; 68 | patch(container, () => elementVoid('div', 'bar')); 69 | 70 | expect(container.childNodes).to.have.length(1); 71 | expect(container.firstChild).to.not.equal(postPatchOneChild); 72 | }); 73 | 74 | it('should default when a config is specified', () => { 75 | const patch = createPatchInner({}); 76 | 77 | patch(container, () => elementVoid('div', 'foo')); 78 | const postPatchOneChild = container.firstChild; 79 | patch(container, () => elementVoid('div', 'foo')); 80 | 81 | expect(container.childNodes).to.have.length(1); 82 | expect(container.firstChild).to.equal(postPatchOneChild); 83 | }); 84 | }); 85 | 86 | describe('for createPatchOuter', () => { 87 | it('should match with the same key and node name', () => { 88 | const patch = createPatchOuter(); 89 | 90 | patch(container, () => { 91 | elementOpen('div'); 92 | elementVoid('div', 'foo'); 93 | elementClose('div'); 94 | }); 95 | const postPatchOneChild = container.firstChild; 96 | patch(container, () => { 97 | elementOpen('div'); 98 | elementVoid('div', 'foo'); 99 | elementClose('div'); 100 | }); 101 | 102 | expect(container.childNodes).to.have.length(1); 103 | expect(container.firstChild).to.equal(postPatchOneChild); 104 | }); 105 | }); 106 | }); 107 | 108 | describe('custom matches', () => { 109 | // For the sake of example, uses a matches function 110 | const patchTripleEquals = createPatchInner({ 111 | matches: (node, nameOrCtor, expectedNameOrCtor, key, expectedKey) => { 112 | return nameOrCtor == expectedNameOrCtor && key === expectedKey; 113 | }, 114 | }); 115 | 116 | it('should reuse nodes when matching', () => { 117 | patchTripleEquals(container, () => elementVoid('div', null)); 118 | const postPatchOneChild = container.firstChild; 119 | patchTripleEquals(container, () => elementVoid('div', null)); 120 | 121 | expect(container.childNodes).to.have.length(1); 122 | expect(container.firstChild).to.equal(postPatchOneChild); 123 | }); 124 | 125 | it('should reuse nodes when matching', () => { 126 | patchTripleEquals(container, () => elementVoid('div', null)); 127 | const postPatchOneChild = container.firstChild; 128 | patchTripleEquals(container, () => elementVoid('div', undefined)); 129 | 130 | expect(container.childNodes).to.have.length(1); 131 | expect(container.firstChild).to.not.equal(postPatchOneChild); 132 | }); 133 | 134 | it('should not effect the default patcher', () => { 135 | patchTripleEquals(container, () => elementVoid('div', null)); 136 | const postPatchOneChild = container.firstChild; 137 | patchInner(container, () => elementVoid('div', undefined)); 138 | 139 | expect(container.childNodes).to.have.length(1); 140 | expect(container.firstChild).to.equal(postPatchOneChild); 141 | }); 142 | 143 | it('should not effect other patchers', () => { 144 | const patchNeverEquals = createPatchInner({ 145 | matches: () => false, 146 | }); 147 | 148 | patchTripleEquals(container, () => elementVoid('div', null)); 149 | const postPatchOneChild = container.firstChild; 150 | patchNeverEquals(container, () => elementVoid('div', null)); 151 | 152 | expect(container.childNodes).to.have.length(1); 153 | expect(container.firstChild).to.not.equal(postPatchOneChild); 154 | }); 155 | }); 156 | }); 157 | -------------------------------------------------------------------------------- /test/functional/patchinner_spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2015 The Incremental DOM Authors. All Rights Reserved. 2 | /** @license SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | // taze: chai from //third_party/javascript/typings/chai 5 | 6 | import { 7 | patch, 8 | patchInner, 9 | elementOpen, 10 | elementOpenStart, 11 | elementOpenEnd, 12 | elementClose, 13 | elementVoid, 14 | text 15 | } from '../../index'; 16 | import { 17 | assertHTMLElement, 18 | } from '../util/dom'; 19 | import * as sinon from 'sinon'; 20 | const {expect} = chai; 21 | 22 | 23 | describe('patching an element\'s children', () => { 24 | let container:HTMLElement; 25 | 26 | beforeEach(() => { 27 | container = document.createElement('div'); 28 | document.body.appendChild(container); 29 | }); 30 | 31 | afterEach(() => { 32 | document.body.removeChild(container); 33 | }); 34 | 35 | describe('with an existing document tree', () => { 36 | let div:HTMLElement; 37 | 38 | function render() { 39 | elementVoid('div', null, null, 40 | 'tabindex', '0'); 41 | } 42 | 43 | beforeEach(function() { 44 | div = document.createElement('div'); 45 | div.setAttribute('tabindex', '-1'); 46 | container.appendChild(div); 47 | }); 48 | 49 | it('should preserve existing nodes', () => { 50 | patchInner(container, render); 51 | const child = container.childNodes[0]; 52 | 53 | expect(child).to.equal(div); 54 | }); 55 | 56 | describe('should return DOM node', () => { 57 | let node:HTMLElement; 58 | 59 | it('from elementOpen', () => { 60 | patchInner(container, () => { 61 | node = elementOpen('div'); 62 | elementClose('div'); 63 | }); 64 | 65 | expect(node).to.equal(div); 66 | }); 67 | 68 | it('from elementClose', () => { 69 | patchInner(container, () => { 70 | elementOpen('div'); 71 | node = assertHTMLElement(elementClose('div')); 72 | }); 73 | 74 | expect(node).to.equal(div); 75 | }); 76 | 77 | it('from elementVoid', () => { 78 | patchInner(container, () => { 79 | node = assertHTMLElement(elementVoid('div')); 80 | }); 81 | 82 | expect(node).to.equal(div); 83 | }); 84 | 85 | it('from elementOpenEnd', () => { 86 | patchInner(container, () => { 87 | elementOpenStart('div'); 88 | node = elementOpenEnd(); 89 | elementClose('div'); 90 | }); 91 | 92 | expect(node).to.equal(div); 93 | }); 94 | }); 95 | }); 96 | 97 | it('should be re-entrant', function() { 98 | const containerOne = document.createElement('div'); 99 | const containerTwo = document.createElement('div'); 100 | 101 | function renderOne() { 102 | elementOpen('div'); 103 | patchInner(containerTwo, renderTwo); 104 | text('hello'); 105 | elementClose('div'); 106 | } 107 | 108 | function renderTwo() { 109 | text('foobar'); 110 | } 111 | 112 | patchInner(containerOne, renderOne); 113 | 114 | expect(containerOne.textContent).to.equal('hello'); 115 | expect(containerTwo.textContent).to.equal('foobar'); 116 | }); 117 | 118 | it('should pass third argument to render function', () => { 119 | 120 | function render(content:unknown) { 121 | text(content as string); 122 | } 123 | 124 | patchInner(container, render, 'foobar'); 125 | 126 | expect(container.textContent).to.equal('foobar'); 127 | }); 128 | 129 | it('should patch a detached node', () => { 130 | const container = document.createElement('div'); 131 | function render() { 132 | elementVoid('span'); 133 | } 134 | 135 | patchInner(container, render); 136 | 137 | expect(assertHTMLElement(container.firstChild).tagName).to.equal('SPAN'); 138 | }); 139 | 140 | it('should throw when an element is unclosed', function() { 141 | expect(() => { 142 | patch(container, () => { 143 | elementOpen('div'); 144 | }); 145 | }).to.throw('One or more tags were not closed:\ndiv'); 146 | }); 147 | }); 148 | 149 | describe('patching a documentFragment', function() { 150 | it('should create the required DOM nodes', function() { 151 | const frag = document.createDocumentFragment(); 152 | 153 | patchInner(frag, function() { 154 | elementOpen('div', null, null, 155 | 'id', 'aDiv'); 156 | elementClose('div'); 157 | }); 158 | 159 | expect(assertHTMLElement(frag.childNodes[0]).id).to.equal('aDiv'); 160 | }); 161 | }); 162 | 163 | describe('patch', () => { 164 | it('should alias patchInner', () => { 165 | expect(patch).to.equal(patchInner); 166 | }); 167 | }); 168 | -------------------------------------------------------------------------------- /test/functional/patchouter_spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018 The Incremental DOM Authors. All Rights Reserved. 2 | /** @license SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | // taze: mocha from //third_party/javascript/typings/mocha 5 | // taze: chai from //third_party/javascript/typings/chai 6 | import {elementClose, elementOpen, elementVoid, patchOuter, text} from '../../index'; 7 | const {expect} = chai; 8 | 9 | 10 | describe('patching an element', () => { 11 | let container; 12 | 13 | beforeEach(() => { 14 | container = document.createElement('div'); 15 | document.body.appendChild(container); 16 | }); 17 | 18 | afterEach(() => { 19 | document.body.removeChild(container); 20 | }); 21 | 22 | it('should update attributes', () => { 23 | function render() { 24 | elementVoid('div', null, null, 'tabindex', '0'); 25 | } 26 | 27 | patchOuter(container, render); 28 | 29 | expect(container.getAttribute('tabindex')).to.equal('0'); 30 | }); 31 | 32 | it('should return the DOM node', () => { 33 | function render() { 34 | elementVoid('div'); 35 | } 36 | 37 | const result = patchOuter(container, render); 38 | 39 | expect(result).to.equal(container); 40 | }); 41 | 42 | it('should update children', () => { 43 | function render() { 44 | elementOpen('div'); 45 | elementVoid('span'); 46 | elementClose('div'); 47 | } 48 | 49 | patchOuter(container, render); 50 | 51 | expect(container.firstChild.tagName).to.equal('SPAN'); 52 | }); 53 | 54 | it('should be re-entrant', function() { 55 | const containerOne = container.appendChild(document.createElement('div')); 56 | const containerTwo = container.appendChild(document.createElement('div')); 57 | 58 | function renderOne() { 59 | elementOpen('div'); 60 | patchOuter(containerTwo, renderTwo); 61 | text('hello'); 62 | elementClose('div'); 63 | } 64 | 65 | function renderTwo() { 66 | elementOpen('div'); 67 | text('foobar'); 68 | elementClose('div'); 69 | } 70 | 71 | patchOuter(containerOne, renderOne); 72 | 73 | expect(containerOne.textContent).to.equal('hello'); 74 | expect(containerTwo.textContent).to.equal('foobar'); 75 | }); 76 | 77 | it('should pass third argument to render function', () => { 78 | function render(content) { 79 | elementOpen('div'); 80 | text(content); 81 | elementClose('div'); 82 | } 83 | 84 | patchOuter(container, render, 'foobar'); 85 | 86 | expect(container.textContent).to.equal('foobar'); 87 | }); 88 | 89 | describe('with an empty patch', () => { 90 | let div; 91 | let prev; 92 | let next; 93 | let result; 94 | 95 | beforeEach(() => { 96 | prev = container.appendChild(document.createElement('div')); 97 | div = container.appendChild(document.createElement('div')); 98 | next = container.appendChild(document.createElement('div')); 99 | 100 | result = patchOuter(div, () => {}); 101 | }); 102 | 103 | it('should remove the DOM node', () => { 104 | expect(div.parentNode).to.be.null; 105 | expect(container.children).to.have.length(2); 106 | }); 107 | 108 | it('should leave prior nodes alone', () => { 109 | expect(container.firstChild).to.equal(prev); 110 | }); 111 | 112 | it('should leaving following nodes alone', () => { 113 | expect(container.lastChild).to.equal(next); 114 | }); 115 | 116 | it('should return null on an empty patch', () => { 117 | expect(result).to.be.null; 118 | }); 119 | }); 120 | 121 | describe('with a matching node', () => { 122 | let div; 123 | let prev; 124 | let next; 125 | let result; 126 | 127 | function render() { 128 | elementVoid('div'); 129 | } 130 | 131 | beforeEach(() => { 132 | prev = container.appendChild(document.createElement('div')); 133 | div = container.appendChild(document.createElement('div')); 134 | next = container.appendChild(document.createElement('div')); 135 | 136 | result = patchOuter(div, render); 137 | }); 138 | 139 | it('should leave the patched node alone', () => { 140 | expect(container.children).to.have.length(3); 141 | expect(container.children[1]).to.equal(div); 142 | }); 143 | 144 | it('should leave prior nodes alone', () => { 145 | expect(container.firstChild).to.equal(prev); 146 | }); 147 | 148 | it('should leaving following nodes alone', () => { 149 | expect(container.lastChild).to.equal(next); 150 | }); 151 | 152 | it('should return the patched node', () => { 153 | expect(result).to.equal(div); 154 | }); 155 | }); 156 | 157 | describe('with a different tag', () => { 158 | describe('without a key', () => { 159 | let div; 160 | let span; 161 | let prev; 162 | let next; 163 | let result; 164 | 165 | function render() { 166 | elementVoid('span'); 167 | } 168 | 169 | beforeEach(() => { 170 | prev = container.appendChild(document.createElement('div')); 171 | div = container.appendChild(document.createElement('div')); 172 | next = container.appendChild(document.createElement('div')); 173 | 174 | result = patchOuter(div, render); 175 | span = container.querySelector('span'); 176 | }); 177 | 178 | it('should replace the DOM node', () => { 179 | expect(container.children).to.have.length(3); 180 | expect(container.children[1]).to.equal(span); 181 | }); 182 | 183 | it('should leave prior nodes alone', () => { 184 | expect(container.firstChild).to.equal(prev); 185 | }); 186 | 187 | it('should leaving following nodes alone', () => { 188 | expect(container.lastChild).to.equal(next); 189 | }); 190 | 191 | it('should return the new DOM node', () => { 192 | expect(result).to.equal(span); 193 | }); 194 | }); 195 | 196 | describe('with a different key', () => { 197 | let div; 198 | let prev; 199 | let next; 200 | let el; 201 | 202 | function render(data) { 203 | el = elementVoid(data.tag, data.key); 204 | } 205 | 206 | beforeEach(() => { 207 | prev = container.appendChild(document.createElement('div')); 208 | div = container.appendChild(document.createElement('div')); 209 | next = container.appendChild(document.createElement('div')); 210 | }); 211 | 212 | describe('when a key changes', () => { 213 | beforeEach(() => { 214 | div.setAttribute('key', 'key0'); 215 | patchOuter(div, render, {tag: 'span', key: 'key1'}); 216 | }); 217 | 218 | it('should replace the DOM node', () => { 219 | expect(container.children).to.have.length(3); 220 | expect(container.children[1]).to.equal(el); 221 | }); 222 | 223 | it('should leave prior nodes alone', () => { 224 | expect(container.firstChild).to.equal(prev); 225 | }); 226 | 227 | it('should leaving following nodes alone', () => { 228 | expect(container.lastChild).to.equal(next); 229 | }); 230 | }); 231 | 232 | describe('when a key is removed', () => { 233 | beforeEach(() => { 234 | div.setAttribute('key', 'key0'); 235 | patchOuter(div, render, {tag: 'span'}); 236 | }); 237 | 238 | it('should replace the DOM node', () => { 239 | expect(container.children).to.have.length(3); 240 | expect(container.children[1].tagName).to.equal('SPAN'); 241 | expect(container.children[1]).to.equal(el); 242 | }); 243 | 244 | it('should leave prior nodes alone', () => { 245 | expect(container.firstChild).to.equal(prev); 246 | }); 247 | 248 | it('should leaving following nodes alone', () => { 249 | expect(container.lastChild).to.equal(next); 250 | }); 251 | }); 252 | 253 | describe('when a key is added', () => { 254 | beforeEach(() => { 255 | patchOuter(div, render, {tag: 'span', key: 'key2'}); 256 | }); 257 | 258 | it('should replace the DOM node', () => { 259 | expect(container.children).to.have.length(3); 260 | expect(container.children[1]).to.equal(el); 261 | }); 262 | 263 | it('should leave prior nodes alone', () => { 264 | expect(container.firstChild).to.equal(prev); 265 | }); 266 | 267 | it('should leaving following nodes alone', () => { 268 | expect(container.lastChild).to.equal(next); 269 | }); 270 | }); 271 | }); 272 | }); 273 | 274 | it('should not hang on to removed elements with keys', () => { 275 | function render() { 276 | elementVoid('div', 'key'); 277 | } 278 | 279 | const divOne = container.appendChild(document.createElement('div')); 280 | patchOuter(divOne, render); 281 | const el = container.firstChild; 282 | patchOuter(el, () => {}); 283 | const divTwo = container.appendChild(document.createElement('div')); 284 | patchOuter(divTwo, render); 285 | 286 | expect(container.children).to.have.length(1); 287 | expect(container.firstChild).to.not.equal(el); 288 | }); 289 | 290 | it('should throw an error when patching too many elements', () => { 291 | const div = container.appendChild(document.createElement('div')); 292 | function render() { 293 | elementVoid('div'); 294 | elementVoid('div'); 295 | } 296 | 297 | expect(() => patchOuter(div, render)) 298 | .to.throw( 299 | 'There must be ' + 300 | 'exactly one top level call corresponding to the patched element.'); 301 | }); 302 | }); 303 | -------------------------------------------------------------------------------- /test/functional/skipNode_spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018 The Incremental DOM Authors. All Rights Reserved. 2 | /** @license SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | // taze: mocha from //third_party/javascript/typings/mocha 5 | // taze: chai from //third_party/javascript/typings/chai 6 | import {elementVoid, patch, skipNode} from '../../index'; 7 | const {expect} = chai; 8 | 9 | 10 | describe('skip', () => { 11 | let container; 12 | let firstChild; 13 | let lastChild; 14 | 15 | beforeEach(() => { 16 | container = document.createElement('div'); 17 | container.innerHTML = '
'; 18 | 19 | firstChild = container.firstChild; 20 | lastChild = container.lastChild; 21 | 22 | document.body.appendChild(container); 23 | }); 24 | 25 | afterEach(() => { 26 | document.body.removeChild(container); 27 | }); 28 | 29 | it('should keep nodes that were skipped at the start', () => { 30 | patch(container, () => { 31 | skipNode(); 32 | elementVoid('span'); 33 | }); 34 | 35 | expect(container.firstChild).to.equal(firstChild); 36 | expect(container.lastChild).to.equal(lastChild); 37 | }); 38 | 39 | it('should keep nodes that were skipped', () => { 40 | patch(container, () => { 41 | elementVoid('div'); 42 | skipNode(); 43 | }); 44 | 45 | expect(container.lastChild).to.equal(lastChild); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /test/functional/skip_spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018 The Incremental DOM Authors. All Rights Reserved. 2 | /** @license SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | // taze: mocha from //third_party/javascript/typings/mocha 5 | // taze: chai from //third_party/javascript/typings/chai 6 | 7 | import {alignWithDOM, elementClose, elementOpen, elementVoid, patch, skip, text, skipNode} from '../../index'; 8 | const {expect} = chai; 9 | 10 | describe('skip', () => { 11 | let container; 12 | 13 | beforeEach(() => { 14 | container = document.createElement('div'); 15 | document.body.appendChild(container); 16 | }); 17 | 18 | afterEach(() => { 19 | document.body.removeChild(container); 20 | }); 21 | 22 | function render(data) { 23 | elementOpen('div'); 24 | if (data.skip) { 25 | skip(); 26 | } else { 27 | text('some '); 28 | text('text'); 29 | } 30 | elementClose('div'); 31 | } 32 | 33 | it('should keep any DOM nodes in the subtree', () => { 34 | patch(container, render, {skip: false}); 35 | patch(container, render, {skip: true}); 36 | 37 | expect(container.textContent).to.equal('some text'); 38 | }); 39 | 40 | it('should throw if an element is declared after skipping', () => { 41 | expect(() => { 42 | patch(container, () => { 43 | skip(); 44 | elementOpen('div'); 45 | elementClose('div'); 46 | }); 47 | }) 48 | .to.throw( 49 | 'elementOpen() may not be called inside an element' + 50 | ' that has called skip().'); 51 | }); 52 | 53 | it('should throw if a text is declared after skipping', () => { 54 | expect(() => { 55 | patch(container, () => { 56 | skip(); 57 | text('text'); 58 | }); 59 | }) 60 | .to.throw( 61 | 'text() may not be called inside an element that has called skip().'); 62 | }); 63 | 64 | it('should throw skip is called after declaring an element', () => { 65 | expect(() => { 66 | patch(container, () => { 67 | elementVoid('div'); 68 | skip(); 69 | }); 70 | }) 71 | .to.throw( 72 | 'skip() must come before any child declarations inside' + 73 | ' the current element.'); 74 | }); 75 | 76 | it('should throw skip is called after declaring a text', () => { 77 | expect(() => { 78 | patch(container, () => { 79 | text('text'); 80 | skip(); 81 | }); 82 | }) 83 | .to.throw( 84 | 'skip() must come before any child declarations' + 85 | ' inside the current element.'); 86 | }); 87 | }); 88 | 89 | describe('alignWithDOM', () => { 90 | let container:HTMLElement; 91 | 92 | beforeEach(() => { 93 | container = document.createElement('div'); 94 | document.body.appendChild(container); 95 | }); 96 | 97 | afterEach(() => { 98 | document.body.removeChild(container); 99 | }); 100 | 101 | function render(condition: boolean, shouldSkip: boolean) { 102 | if (condition) { 103 | elementVoid('img'); 104 | } 105 | if (shouldSkip) { 106 | alignWithDOM('div', 1); 107 | } else { 108 | elementOpen('div', 1); 109 | text('Hello'); 110 | elementClose('div'); 111 | } 112 | } 113 | it('should skip the correct element when used with conditional elements', () => { 114 | patch(container, () => { 115 | render(true, false); 116 | }); 117 | expect(container.children[1]!.innerHTML).to.equal('Hello'); 118 | container.children[1]!.innerHTML = 'Hola'; 119 | patch(container, () => { 120 | render(false, true); 121 | }); 122 | expect(container.childElementCount).to.equal(1); 123 | // When condition is false, the current node will be at 124 | // alignWithDOM will then pull the second
up to the 125 | // current position and diff it. The will then be deleted. 126 | expect(container.children[0]!.innerHTML).to.equal('Hola'); 127 | }); 128 | }); -------------------------------------------------------------------------------- /test/functional/styles_spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018 The Incremental DOM Authors. All Rights Reserved. 2 | /** @license SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | // taze: mocha from //third_party/javascript/typings/mocha 5 | // taze: chai from //third_party/javascript/typings/chai 6 | import {elementVoid, patch} from '../../index'; 7 | const {expect} = chai; 8 | 9 | describe('style updates', () => { 10 | let container; 11 | 12 | beforeEach(() => { 13 | container = document.createElement('div'); 14 | document.body.appendChild(container); 15 | }); 16 | 17 | afterEach(() => { 18 | document.body.removeChild(container); 19 | }); 20 | 21 | function browserSupportsCssCustomProperties() { 22 | const style = document.createElement('div').style; 23 | style.setProperty('--prop', 'value'); 24 | return style.getPropertyValue('--prop') === 'value'; 25 | } 26 | 27 | function render(style) { 28 | elementVoid('div', null, null, 'style', style); 29 | } 30 | 31 | it('should render with the correct style properties for objects', () => { 32 | patch(container, () => render({color: 'white', backgroundColor: 'red'})); 33 | const el = container.childNodes[0]; 34 | 35 | expect(el.style.color).to.equal('white'); 36 | expect(el.style.backgroundColor).to.equal('red'); 37 | }); 38 | 39 | if (browserSupportsCssCustomProperties()) { 40 | it('should apply custom properties', () => { 41 | patch(container, () => render({'--some-var': 'blue'})); 42 | const el = container.childNodes[0]; 43 | 44 | expect(el.style.getPropertyValue('--some-var')).to.equal('blue'); 45 | }); 46 | } 47 | 48 | it('should handle dashes in property names', () => { 49 | patch(container, () => render({'background-color': 'red'})); 50 | const el = container.childNodes[0]; 51 | 52 | expect(el.style.backgroundColor).to.equal('red'); 53 | }); 54 | 55 | it('should update the correct style properties', () => { 56 | patch(container, () => render({color: 'white'})); 57 | patch(container, () => render({color: 'red'})); 58 | const el = container.childNodes[0]; 59 | 60 | expect(el.style.color).to.equal('red'); 61 | }); 62 | 63 | it('should remove properties not present in the new object', () => { 64 | patch(container, () => render({color: 'white'})); 65 | patch(container, () => render({backgroundColor: 'red'})); 66 | const el = container.childNodes[0]; 67 | 68 | expect(el.style.color).to.equal(''); 69 | expect(el.style.backgroundColor).to.equal('red'); 70 | }); 71 | 72 | it('should render with the correct style properties for strings', () => { 73 | patch(container, () => render('color: white; background-color: red;')); 74 | const el = container.childNodes[0]; 75 | 76 | expect(el.style.color).to.equal('white'); 77 | expect(el.style.backgroundColor).to.equal('red'); 78 | }); 79 | }); 80 | -------------------------------------------------------------------------------- /test/functional/text_nodes_spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018 The Incremental DOM Authors. All Rights Reserved. 2 | /** @license SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | // taze: mocha from //third_party/javascript/typings/mocha 5 | // taze: chai from //third_party/javascript/typings/chai 6 | import {elementOpenStart, patch, text} from '../../index'; 7 | const {expect} = chai; 8 | 9 | 10 | describe('text nodes', () => { 11 | let container: HTMLDivElement; 12 | 13 | beforeEach(() => { 14 | container = document.createElement('div'); 15 | document.body.appendChild(container); 16 | }); 17 | 18 | afterEach(() => { 19 | document.body.removeChild(container); 20 | }); 21 | 22 | describe('when created', () => { 23 | it('should render a text node with the specified value', () => { 24 | patch(container, () => { 25 | text('Hello world!'); 26 | }); 27 | const node = container.childNodes[0]; 28 | 29 | expect(node.textContent).to.equal('Hello world!'); 30 | expect(node).to.be.instanceof(Text); 31 | }); 32 | 33 | it('should allow for multiple text nodes under one parent element', () => { 34 | patch(container, () => { 35 | text('Hello '); 36 | text('World'); 37 | text('!'); 38 | }); 39 | 40 | expect(container.textContent).to.equal('Hello World!'); 41 | }); 42 | 43 | it('should throw when inside virtual attributes element', () => { 44 | expect(() => { 45 | patch(container, () => { 46 | elementOpenStart('div'); 47 | text('Hello'); 48 | }); 49 | }) 50 | .to.throw( 51 | 'text() can not be called between elementOpenStart()' + 52 | ' and elementOpenEnd().'); 53 | }); 54 | }); 55 | 56 | describe('when updated after the DOM is updated', () => { 57 | // This avoids an Edge bug; see 58 | // https://github.com/google/incremental-dom/pull/398#issuecomment-497339108 59 | it('should do nothng', () => { 60 | patch(container, () => text('Hello')); 61 | 62 | container.firstChild!.nodeValue = 'Hello World!'; 63 | 64 | const mo = new MutationObserver(() => {}); 65 | mo.observe(container, {subtree: true, characterData: true}); 66 | 67 | patch(container, () => text('Hello World!')); 68 | expect(mo.takeRecords()).to.be.empty; 69 | expect(container.textContent).to.equal('Hello World!'); 70 | }); 71 | }); 72 | 73 | describe('with conditional text', () => { 74 | function render(data) { 75 | text(data); 76 | } 77 | 78 | it('should update the DOM when the text is updated', () => { 79 | patch(container, () => render('Hello')); 80 | patch(container, () => render('Hello World!')); 81 | const node = container.childNodes[0]; 82 | 83 | expect(node.textContent).to.equal('Hello World!'); 84 | }); 85 | }); 86 | }); 87 | -------------------------------------------------------------------------------- /test/functional/virtual_attributes_spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018 The Incremental DOM Authors. All Rights Reserved. 2 | /** @license SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | // taze: chai from //third_party/javascript/typings/chai 5 | import {attr, elementClose, elementOpen, elementOpenEnd, elementOpenStart, patch} from '../../index'; 6 | const {expect} = chai; 7 | 8 | describe('virtual attribute updates', () => { 9 | let container; 10 | 11 | beforeEach(() => { 12 | container = document.createElement('div'); 13 | document.body.appendChild(container); 14 | }); 15 | 16 | afterEach(() => { 17 | document.body.removeChild(container); 18 | }); 19 | 20 | describe('for conditional attributes', () => { 21 | function render(obj) { 22 | elementOpenStart('div'); 23 | if (obj.key) { 24 | attr('data-expanded', obj.key); 25 | } 26 | elementOpenEnd(); 27 | elementClose('div'); 28 | } 29 | 30 | it('should be present when specified', () => { 31 | patch(container, () => render({key: 'hello'})); 32 | const el = container.childNodes[0]; 33 | 34 | expect(el.getAttribute('data-expanded')).to.equal('hello'); 35 | }); 36 | 37 | it('should be not present when not specified', () => { 38 | patch(container, () => render({key: false})); 39 | const el = container.childNodes[0]; 40 | 41 | expect(el.getAttribute('data-expanded')).to.equal(null); 42 | }); 43 | 44 | it('should update the DOM when they change', () => { 45 | patch(container, () => render({key: 'foo'})); 46 | patch(container, () => render({key: 'bar'})); 47 | const el = container.childNodes[0]; 48 | 49 | expect(el.getAttribute('data-expanded')).to.equal('bar'); 50 | }); 51 | 52 | it('should correctly apply attributes during nested patches', () => { 53 | const otherContainer = document.createElement('div'); 54 | 55 | patch(container, () => { 56 | elementOpenStart('div'); 57 | attr('parrentAttrOne', 'pOne'); 58 | 59 | patch(otherContainer, () => { 60 | elementOpenStart('div'); 61 | attr('childAttrOne', 'cOne'); 62 | elementOpenEnd(); 63 | elementClose('div'); 64 | }); 65 | 66 | attr('parrentAttrTwo', 'pTwo'); 67 | elementOpenEnd(); 68 | 69 | elementClose('div'); 70 | }); 71 | 72 | const parentAttributes = container.children[0].attributes; 73 | expect(parentAttributes).to.have.length(2); 74 | expect(parentAttributes['parrentAttrOne'].value).to.equal('pOne'); 75 | expect(parentAttributes['parrentAttrTwo'].value).to.equal('pTwo'); 76 | const childAttributes = otherContainer.children[0].attributes; 77 | expect(childAttributes).to.have.length(1); 78 | expect(childAttributes['childAttrOne'].value).to.equal('cOne'); 79 | }); 80 | }); 81 | 82 | it('should throw when a virtual attributes element is unclosed', () => { 83 | expect(() => { 84 | patch(container, () => { 85 | elementOpenStart('div'); 86 | }); 87 | }) 88 | .to.throw( 89 | 'elementOpenEnd() must be called after calling' + 90 | ' elementOpenStart().'); 91 | }); 92 | 93 | it(`should throw when virtual attributes element is 94 | closed without being opened`, 95 | () => { 96 | expect(() => { 97 | patch(container, () => { 98 | elementOpenEnd(); 99 | }); 100 | }) 101 | .to.throw( 102 | 'elementOpenEnd() can only be called' + 103 | ' after calling elementOpenStart().'); 104 | }); 105 | 106 | it('should throw when opening an element inside a virtual attributes element', 107 | () => { 108 | expect(() => { 109 | patch(container, () => { 110 | elementOpenStart('div'); 111 | elementOpen('div'); 112 | }); 113 | }) 114 | .to.throw( 115 | 'elementOpen() can not be called between' + 116 | ' elementOpenStart() and elementOpenEnd().'); 117 | }); 118 | 119 | it('should throw when opening a virtual attributes element' + 120 | ' inside a virtual attributes element', 121 | () => { 122 | expect(() => { 123 | patch(container, () => { 124 | elementOpenStart('div'); 125 | elementOpenStart('div'); 126 | }); 127 | }) 128 | .to.throw( 129 | 'elementOpenStart() can not be called between' + 130 | ' elementOpenStart() and elementOpenEnd().'); 131 | }); 132 | 133 | it('should throw when closing an element inside a virtual attributes element', 134 | () => { 135 | expect(() => { 136 | patch(container, () => { 137 | elementOpenStart('div'); 138 | elementClose('div'); 139 | }); 140 | }) 141 | .to.throw( 142 | 'elementClose() can not be called between' + 143 | ' elementOpenStart() and elementOpenEnd().'); 144 | }); 145 | }); 146 | -------------------------------------------------------------------------------- /test/integration/keyed_items_spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018 The Incremental DOM Authors. All Rights Reserved. 2 | /** @license SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | import {elementOpen, elementClose, patch} from '../../index'; 5 | import * as Sinon from 'sinon'; 6 | 7 | const {expect} = chai; 8 | 9 | /* 10 | * These tests just capture the current state of mutations that occur when 11 | * changing the items. These could change in the future. 12 | */ 13 | describe('keyed items', () => { 14 | let container: HTMLElement; 15 | const sandbox = Sinon.sandbox.create(); 16 | const mutationObserverConfig = { 17 | childList: true, 18 | subtree: true, 19 | }; 20 | 21 | beforeEach(() => { 22 | container = document.createElement('div'); 23 | document.body.appendChild(container); 24 | }); 25 | 26 | afterEach(() => { 27 | sandbox.restore(); 28 | document.body.removeChild(container); 29 | }); 30 | 31 | /** 32 | * @param container 33 | */ 34 | function createMutationObserver(container: Element): MutationObserver { 35 | const mo = new MutationObserver(() => {}); 36 | mo.observe(container, mutationObserverConfig); 37 | 38 | return mo; 39 | } 40 | 41 | /** 42 | * @param keys 43 | */ 44 | function render(keys: number[]) { 45 | keys.forEach((key) => { 46 | elementOpen('div', key); 47 | elementClose('div'); 48 | }); 49 | } 50 | 51 | it('should cause no mutations when the items stay the same', () => { 52 | patch(container, () => render([1, 2, 3])); 53 | 54 | const mo = createMutationObserver(container); 55 | patch(container, () => render([1, 2, 3])); 56 | 57 | const records = mo.takeRecords(); 58 | expect(records).to.be.empty; 59 | }); 60 | 61 | it('causes only one mutation when adding a new item', () => { 62 | patch(container, () => render([1, 2, 3])); 63 | 64 | const mo = createMutationObserver(container); 65 | patch(container, () => render([0, 1, 2, 3])); 66 | 67 | const records = mo.takeRecords(); 68 | expect(records).to.have.length(1); 69 | }); 70 | 71 | it('cause a removal and addition when moving forwards', () => { 72 | patch(container, () => render([1, 2, 3])); 73 | 74 | const mo = createMutationObserver(container); 75 | patch(container, () => render([3, 1, 2])); 76 | 77 | const records = mo.takeRecords(); 78 | expect(records).to.have.length(2); 79 | expect(records[0].addedNodes).to.have.length(0); 80 | expect(records[0].removedNodes).to.have.length(1); 81 | expect(records[1].addedNodes).to.have.length(1); 82 | expect(records[1].removedNodes).to.have.length(0); 83 | }); 84 | 85 | it('causes mutations for each item when removing from the start', () => { 86 | patch(container, () => render([1, 2, 3, 4])); 87 | 88 | const mo = createMutationObserver(container); 89 | patch(container, () => render([2, 3, 4])); 90 | 91 | const records = mo.takeRecords(); 92 | // 7 Mutations: two for each of the nodes moving forward and one for the 93 | // removal. 94 | expect(records).to.have.length(7); 95 | }); 96 | }); 97 | -------------------------------------------------------------------------------- /test/karma.conf.js: -------------------------------------------------------------------------------- 1 | module.exports = function(config) { 2 | // Karma tests fail with a Chrome sandbox error on macOS, so disable the 3 | // sandbox. 4 | if (process.platform === 'darwin') { 5 | config.set({ 6 | browsers: ['ChromeHeadless_macos_fixed'], 7 | customLaunchers: { 8 | ChromeHeadless_macos_fixed: { 9 | base: 'ChromeHeadless', 10 | flags: [ 11 | '--no-sandbox', 12 | ], 13 | }, 14 | }, 15 | }); 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /test/unit/changes_spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018 The Incremental DOM Authors. All Rights Reserved. 2 | /** @license SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | // taze: chai from //third_party/javascript/typings/chai 5 | 6 | import * as Sinon from 'sinon'; 7 | 8 | import {flush, queueChange} from '../../src/changes'; 9 | 10 | const {expect} = chai; 11 | 12 | describe('changes', () => { 13 | it('should call the update functions for all changes', () => { 14 | const spyOne = Sinon.spy(); 15 | const spyTwo = Sinon.spy(); 16 | 17 | queueChange(spyOne, 'a', 'b', 'c', 'd'); 18 | queueChange(spyTwo, 'd', 'e', 'f', 'g'); 19 | flush(); 20 | 21 | expect(spyOne).to.have.been.calledOnce.to.have.been.calledWith( 22 | 'a', 'b', 'c', 'd'); 23 | expect(spyTwo).to.have.been.calledOnce.to.have.been.calledWith( 24 | 'd', 'e', 'f', 'g'); 25 | }); 26 | 27 | it('should clear the changes after flush', () => { 28 | const spy = Sinon.spy(); 29 | queueChange(spy, 'a', 'b', 'c', 'd'); 30 | flush(); 31 | flush(); 32 | 33 | expect(spy).to.have.been.calledOnce.to.have.been.calledWith('a', 'b', 'c', 'd'); 34 | }); 35 | 36 | it('should allow re-entrant usage', () => { 37 | const innerSpy = Sinon.spy(); 38 | const outerSpyOne = Sinon.spy(() => { 39 | queueChange(innerSpy, 'd', 'e', 'f', 'g'); 40 | queueChange(innerSpy, 'g', 'h', 'i', 'j'); 41 | flush(); 42 | }); 43 | const outerSpyTwo = Sinon.spy(); 44 | 45 | queueChange(outerSpyOne, 'a', 'b', 'c', 'd'); 46 | 47 | queueChange(outerSpyTwo, 'j', 'k', 'l', 'm'); 48 | flush(); 49 | 50 | expect(innerSpy) 51 | .to.have.been.calledTwice.to.have.been.calledWith('d', 'e', 'f', 'g') 52 | .to.have.been.calledWith('g', 'h', 'i', 'j'); 53 | expect(outerSpyOne) 54 | .to.have.been.calledOnce.to.have.been.calledWith('a', 'b', 'c', 'd'); 55 | expect(outerSpyTwo) 56 | .to.have.been.calledOnce.to.have.been.calledWith('j', 'k', 'l', 'm'); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /test/unit/diff_spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018 The Incremental DOM Authors. All Rights Reserved. 2 | /** @license SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | // taze: chai from //third_party/javascript/typings/chai 5 | import * as Sinon from 'sinon'; 6 | import {calculateDiff} from '../../src/diff'; 7 | import {attributes} from '../../src/attributes'; 8 | const {expect} = chai; 9 | 10 | describe('calculateDiff', () => { 11 | const updateCtx = {}; 12 | let updateFn: Sinon.SinonSpy; 13 | 14 | beforeEach(() => { 15 | updateFn = Sinon.spy(); 16 | }); 17 | 18 | it('should call the update function for added items', () => { 19 | const prev: string[] = []; 20 | const next = ['name1', 'value1', 'name2', 'value2']; 21 | 22 | calculateDiff(prev, next, updateCtx, updateFn, attributes); 23 | 24 | expect(updateFn) 25 | .to.have.been.calledTwice.to.have.been 26 | .calledWith(updateCtx, 'name1', 'value1') 27 | .to.have.been.calledWith(updateCtx, 'name2', 'value2'); 28 | }); 29 | 30 | it('should call the update function for removed items', () => { 31 | const prev = ['name1', 'value1', 'name2', 'value2']; 32 | const next: string[] = []; 33 | 34 | calculateDiff(prev, next, updateCtx, updateFn, attributes); 35 | 36 | expect(updateFn) 37 | .to.have.been.calledTwice.to.have.been 38 | .calledWith(updateCtx, 'name1', undefined) 39 | .to.have.been.calledWith(updateCtx, 'name2', undefined); 40 | }); 41 | 42 | it('should not call the update function if there are no changes', () => { 43 | const prev = ['name', 'value']; 44 | const next = ['name', 'value']; 45 | 46 | calculateDiff(prev, next, updateCtx, updateFn, attributes); 47 | 48 | expect(updateFn).to.have.been.not.called; 49 | }); 50 | 51 | it('should handle items appearing earlier', () => { 52 | const prev = ['name1', 'value1']; 53 | const next = ['name2', 'value2', 'name1', 'value1']; 54 | 55 | calculateDiff(prev, next, updateCtx, updateFn, attributes); 56 | 57 | expect(updateFn).to.have.been.calledOnce.to.have.been.calledWith( 58 | updateCtx, 'name2', 'value2'); 59 | }); 60 | 61 | it('should handle changed item ordering', () => { 62 | const prev = ['name1', 'value1', 'name2', 'value2']; 63 | const next = ['name2', 'value2', 'name1', 'value1']; 64 | 65 | calculateDiff(prev, next, updateCtx, updateFn, attributes); 66 | 67 | expect(updateFn).to.have.been.not.called; 68 | }); 69 | }); 70 | -------------------------------------------------------------------------------- /test/util/dom.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018 The Incremental DOM Authors. All Rights Reserved. 2 | /** @license SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | const BROWSER_SUPPORTS_SHADOW_DOM = 'ShadowRoot' in window; 5 | 6 | 7 | const attachShadow = (el: HTMLElement) => { 8 | return el.attachShadow ? el.attachShadow({mode: 'closed'}) : 9 | // tslint:disable:no-any 10 | (el as any).createShadowRoot(); 11 | }; 12 | 13 | function assertElement(el: Node|null): Element { 14 | if (el instanceof Element) { 15 | return el; 16 | } 17 | throw new Error('Expected element to be Element'); 18 | } 19 | 20 | function assertHTMLElement(el: Node|null): HTMLElement { 21 | if (el instanceof HTMLElement) { 22 | return el; 23 | } 24 | throw new Error('Expected element to be HTMLElement'); 25 | } 26 | 27 | 28 | export { 29 | BROWSER_SUPPORTS_SHADOW_DOM, 30 | assertElement, 31 | assertHTMLElement, 32 | attachShadow 33 | }; 34 | -------------------------------------------------------------------------------- /test/util/globals.js: -------------------------------------------------------------------------------- 1 | // Copyright 2017 The Incremental DOM Authors. All Rights Reserved. 2 | /** @license SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | // This is in a separate file as a global to prevent Babel from transpiling 5 | // classes. Transpiled classes do not work with customElements.define. 6 | if (window.customElements) { 7 | window.MyElementDefine = class extends HTMLElement { 8 | constructor() { 9 | super(); 10 | } 11 | }; 12 | 13 | window.customElements.define('my-element-define', window.MyElementDefine); 14 | } 15 | 16 | if (document.registerElement) { 17 | window.MyElementRegister = document.registerElement('my-element-register', { 18 | prototype: Object.create(HTMLElement.prototype) 19 | }); 20 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "rootDir": ".", 4 | "outDir": "genfiles", 5 | "allowSyntheticDefaultImports": false, 6 | "allowUnreachableCode": false, 7 | "allowUnusedLabels": false, 8 | "declaration": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "noFallthroughCasesInSwitch": true, 11 | "noEmitOnError": true, 12 | "noImplicitAny": false, 13 | "noImplicitReturns": true, 14 | "pretty": true, 15 | "strict": true, 16 | "module": "commonjs", 17 | "target": "es5", 18 | "lib": ["es2015", "dom"], 19 | "sourceMap": true 20 | }, 21 | "include": [ 22 | "index.ts", 23 | "src/*.ts", 24 | "test/*.ts", 25 | "test/**/*.ts" 26 | ], 27 | "exclude": [ 28 | "node_modules" 29 | ] 30 | } 31 | --------------------------------------------------------------------------------