diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2734f9dcc4ec..98fe7b876cd7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,9 @@
# Svelte changelog
+## Unreleased
+
+* Add `{#key}` block for keying arbitrary content on an expression ([#1469](https://github.com/sveltejs/svelte/issues/1469))
+
## 3.27.0
* Add `|nonpassive` event modifier, explicitly passing `passive: false` ([#2068](https://github.com/sveltejs/svelte/issues/2068))
diff --git a/site/content/docs/02-template-syntax.md b/site/content/docs/02-template-syntax.md
index f7e1ae351734..d955f650e20a 100644
--- a/site/content/docs/02-template-syntax.md
+++ b/site/content/docs/02-template-syntax.md
@@ -342,6 +342,33 @@ If you don't care about the pending state, you can also omit the initial block.
{/await}
```
+### {#key ...}
+
+```sv
+{#key expression}...{/key}
+```
+
+Key blocks destroy and recreate their contents when the value of an expression changes.
+
+---
+
+This is useful if you want an element to play its transition whenever a value changes.
+
+```sv
+{#key value}
+
{value}
+{/key}
+```
+
+---
+
+When used around components, this will cause them to be reinstantiated and reinitialised.
+
+```sv
+{#key value}
+
+{/key}
+```
### {@html ...}
diff --git a/src/compiler/compile/nodes/KeyBlock.ts b/src/compiler/compile/nodes/KeyBlock.ts
new file mode 100644
index 000000000000..356210b6b0f3
--- /dev/null
+++ b/src/compiler/compile/nodes/KeyBlock.ts
@@ -0,0 +1,19 @@
+import Expression from "./shared/Expression";
+import map_children from "./shared/map_children";
+import AbstractBlock from "./shared/AbstractBlock";
+
+export default class KeyBlock extends AbstractBlock {
+ type: "KeyBlock";
+
+ expression: Expression;
+
+ constructor(component, parent, scope, info) {
+ super(component, parent, scope, info);
+
+ this.expression = new Expression(component, this, scope, info.expression);
+
+ this.children = map_children(component, this, scope, info.children);
+
+ this.warn_if_empty_block();
+ }
+}
diff --git a/src/compiler/compile/nodes/interfaces.ts b/src/compiler/compile/nodes/interfaces.ts
index 752168a49d41..51d7b17e07b7 100644
--- a/src/compiler/compile/nodes/interfaces.ts
+++ b/src/compiler/compile/nodes/interfaces.ts
@@ -18,6 +18,7 @@ import Fragment from './Fragment';
import Head from './Head';
import IfBlock from './IfBlock';
import InlineComponent from './InlineComponent';
+import KeyBlock from './KeyBlock';
import Let from './Let';
import MustacheTag from './MustacheTag';
import Options from './Options';
@@ -50,6 +51,7 @@ export type INode = Action
| Head
| IfBlock
| InlineComponent
+| KeyBlock
| Let
| MustacheTag
| Options
diff --git a/src/compiler/compile/nodes/shared/map_children.ts b/src/compiler/compile/nodes/shared/map_children.ts
index dcdc52f86d16..5d5da223fb02 100644
--- a/src/compiler/compile/nodes/shared/map_children.ts
+++ b/src/compiler/compile/nodes/shared/map_children.ts
@@ -6,6 +6,7 @@ import Element from '../Element';
import Head from '../Head';
import IfBlock from '../IfBlock';
import InlineComponent from '../InlineComponent';
+import KeyBlock from '../KeyBlock';
import MustacheTag from '../MustacheTag';
import Options from '../Options';
import RawMustacheTag from '../RawMustacheTag';
@@ -28,6 +29,7 @@ function get_constructor(type) {
case 'Head': return Head;
case 'IfBlock': return IfBlock;
case 'InlineComponent': return InlineComponent;
+ case 'KeyBlock': return KeyBlock;
case 'MustacheTag': return MustacheTag;
case 'Options': return Options;
case 'RawMustacheTag': return RawMustacheTag;
diff --git a/src/compiler/compile/render_dom/wrappers/Fragment.ts b/src/compiler/compile/render_dom/wrappers/Fragment.ts
index a0984b69b920..853a41f3cc64 100644
--- a/src/compiler/compile/render_dom/wrappers/Fragment.ts
+++ b/src/compiler/compile/render_dom/wrappers/Fragment.ts
@@ -6,6 +6,7 @@ import EachBlock from './EachBlock';
import Element from './Element/index';
import Head from './Head';
import IfBlock from './IfBlock';
+import KeyBlock from './KeyBlock';
import InlineComponent from './InlineComponent/index';
import MustacheTag from './MustacheTag';
import RawMustacheTag from './RawMustacheTag';
@@ -30,6 +31,7 @@ const wrappers = {
Head,
IfBlock,
InlineComponent,
+ KeyBlock,
MustacheTag,
Options: null,
RawMustacheTag,
diff --git a/src/compiler/compile/render_dom/wrappers/KeyBlock.ts b/src/compiler/compile/render_dom/wrappers/KeyBlock.ts
new file mode 100644
index 000000000000..359fb6946f27
--- /dev/null
+++ b/src/compiler/compile/render_dom/wrappers/KeyBlock.ts
@@ -0,0 +1,136 @@
+import Wrapper from "./shared/Wrapper";
+import Renderer from "../Renderer";
+import Block from "../Block";
+import EachBlock from "../../nodes/EachBlock";
+import KeyBlock from "../../nodes/KeyBlock";
+import create_debugging_comment from "./shared/create_debugging_comment";
+import FragmentWrapper from "./Fragment";
+import { b, x } from "code-red";
+import { Identifier } from "estree";
+
+export default class KeyBlockWrapper extends Wrapper {
+ node: KeyBlock;
+ fragment: FragmentWrapper;
+ block: Block;
+ dependencies: string[];
+ var: Identifier = { type: "Identifier", name: "key_block" };
+
+ constructor(
+ renderer: Renderer,
+ block: Block,
+ parent: Wrapper,
+ node: EachBlock,
+ strip_whitespace: boolean,
+ next_sibling: Wrapper
+ ) {
+ super(renderer, block, parent, node);
+
+ this.cannot_use_innerhtml();
+ this.not_static_content();
+
+ this.dependencies = node.expression.dynamic_dependencies();
+
+ if (this.dependencies.length) {
+ block = block.child({
+ comment: create_debugging_comment(node, renderer.component),
+ name: renderer.component.get_unique_name("create_key_block"),
+ type: "key"
+ });
+ renderer.blocks.push(block);
+ }
+
+ this.block = block;
+ this.fragment = new FragmentWrapper(
+ renderer,
+ this.block,
+ node.children,
+ parent,
+ strip_whitespace,
+ next_sibling
+ );
+ }
+
+ render(block: Block, parent_node: Identifier, parent_nodes: Identifier) {
+ if (this.dependencies.length === 0) {
+ this.render_static_key(block, parent_node, parent_nodes);
+ } else {
+ this.render_dynamic_key(block, parent_node, parent_nodes);
+ }
+ }
+
+ render_static_key(_block: Block, parent_node: Identifier, parent_nodes: Identifier) {
+ this.fragment.render(this.block, parent_node, parent_nodes);
+ }
+
+ render_dynamic_key(block: Block, parent_node: Identifier, parent_nodes: Identifier) {
+ this.fragment.render(
+ this.block,
+ null,
+ (x`#nodes` as unknown) as Identifier
+ );
+
+ const has_transitions = !!(
+ this.block.has_intro_method || this.block.has_outro_method
+ );
+ const dynamic = this.block.has_update_method;
+
+ const previous_key = block.get_unique_name('previous_key');
+ const snippet = this.node.expression.manipulate(block);
+ block.add_variable(previous_key, snippet);
+
+ const not_equal = this.renderer.component.component_options.immutable ? x`@not_equal` : x`@safe_not_equal`;
+ const condition = x`${this.renderer.dirty(this.dependencies)} && ${not_equal}(${previous_key}, ${previous_key} = ${snippet})`;
+
+ block.chunks.init.push(b`
+ let ${this.var} = ${this.block.name}(#ctx);
+ `);
+ block.chunks.create.push(b`${this.var}.c();`);
+ if (this.renderer.options.hydratable) {
+ block.chunks.claim.push(b`${this.var}.l(${parent_nodes});`);
+ }
+ block.chunks.mount.push(
+ b`${this.var}.m(${parent_node || "#target"}, ${
+ parent_node ? "null" : "#anchor"
+ });`
+ );
+ const anchor = this.get_or_create_anchor(block, parent_node, parent_nodes);
+ const body = b`
+ ${
+ has_transitions
+ ? b`
+ @group_outros();
+ @transition_out(${this.var}, 1, 1, @noop);
+ @check_outros();
+ `
+ : b`${this.var}.d(1);`
+ }
+ ${this.var} = ${this.block.name}(#ctx);
+ ${this.var}.c();
+ ${has_transitions && b`@transition_in(${this.var})`}
+ ${this.var}.m(${this.get_update_mount_node(anchor)}, ${anchor});
+ `;
+
+ if (dynamic) {
+ block.chunks.update.push(b`
+ if (${condition}) {
+ ${body}
+ } else {
+ ${this.var}.p(#ctx, #dirty);
+ }
+ `);
+ } else {
+ block.chunks.update.push(b`
+ if (${condition}) {
+ ${body}
+ }
+ `);
+ }
+
+ if (has_transitions) {
+ block.chunks.intro.push(b`@transition_in(${this.var})`);
+ block.chunks.outro.push(b`@transition_out(${this.var})`);
+ }
+
+ block.chunks.destroy.push(b`${this.var}.d(detaching)`);
+ }
+}
diff --git a/src/compiler/compile/render_ssr/Renderer.ts b/src/compiler/compile/render_ssr/Renderer.ts
index fb9216327c68..c633ff8b0ab5 100644
--- a/src/compiler/compile/render_ssr/Renderer.ts
+++ b/src/compiler/compile/render_ssr/Renderer.ts
@@ -7,6 +7,7 @@ import Head from './handlers/Head';
import HtmlTag from './handlers/HtmlTag';
import IfBlock from './handlers/IfBlock';
import InlineComponent from './handlers/InlineComponent';
+import KeyBlock from './handlers/KeyBlock';
import Slot from './handlers/Slot';
import Tag from './handlers/Tag';
import Text from './handlers/Text';
@@ -30,6 +31,7 @@ const handlers: Record = {
Head,
IfBlock,
InlineComponent,
+ KeyBlock,
MustacheTag: Tag, // TODO MustacheTag is an anachronism
Options: noop,
RawMustacheTag: HtmlTag,
diff --git a/src/compiler/compile/render_ssr/handlers/KeyBlock.ts b/src/compiler/compile/render_ssr/handlers/KeyBlock.ts
new file mode 100644
index 000000000000..33a6681280d9
--- /dev/null
+++ b/src/compiler/compile/render_ssr/handlers/KeyBlock.ts
@@ -0,0 +1,6 @@
+import KeyBlock from '../../nodes/KeyBlock';
+import Renderer, { RenderOptions } from '../Renderer';
+
+export default function(node: KeyBlock, renderer: Renderer, options: RenderOptions) {
+ renderer.render(node.children, options);
+}
diff --git a/src/compiler/parse/state/mustache.ts b/src/compiler/parse/state/mustache.ts
index dc26d994df97..b72b77c30bd0 100644
--- a/src/compiler/parse/state/mustache.ts
+++ b/src/compiler/parse/state/mustache.ts
@@ -38,7 +38,7 @@ export default function mustache(parser: Parser) {
parser.allow_whitespace();
- // {/if}, {/each} or {/await}
+ // {/if}, {/each}, {/await} or {/key}
if (parser.eat('/')) {
let block = parser.current();
let expected;
@@ -63,6 +63,8 @@ export default function mustache(parser: Parser) {
expected = 'each';
} else if (block.type === 'AwaitBlock') {
expected = 'await';
+ } else if (block.type === 'KeyBlock') {
+ expected = 'key';
} else {
parser.error({
code: `unexpected-block-close`,
@@ -221,10 +223,12 @@ export default function mustache(parser: Parser) {
type = 'EachBlock';
} else if (parser.eat('await')) {
type = 'AwaitBlock';
+ } else if (parser.eat('key')) {
+ type = 'KeyBlock';
} else {
parser.error({
code: `expected-block-type`,
- message: `Expected if, each or await`
+ message: `Expected if, each, await or key`
});
}
diff --git a/test/runtime/samples/key-block-2/_config.js b/test/runtime/samples/key-block-2/_config.js
new file mode 100644
index 000000000000..a7c53bd91fb4
--- /dev/null
+++ b/test/runtime/samples/key-block-2/_config.js
@@ -0,0 +1,14 @@
+// with reactive content beside `key`
+export default {
+ html: `00
`,
+ async test({ assert, component, target, window }) {
+ const div = target.querySelector('div');
+ component.reactive = 2;
+ assert.htmlEqual(target.innerHTML, `02
`);
+ assert.strictEqual(div, target.querySelector('div'));
+
+ component.value = 5;
+ assert.htmlEqual(target.innerHTML, `52
`);
+ assert.notStrictEqual(div, target.querySelector('div'));
+ }
+};
diff --git a/test/runtime/samples/key-block-2/main.svelte b/test/runtime/samples/key-block-2/main.svelte
new file mode 100644
index 000000000000..466d20b10a73
--- /dev/null
+++ b/test/runtime/samples/key-block-2/main.svelte
@@ -0,0 +1,8 @@
+
+
+{#key value}
+ {value}{reactive}
+{/key}
\ No newline at end of file
diff --git a/test/runtime/samples/key-block-3/_config.js b/test/runtime/samples/key-block-3/_config.js
new file mode 100644
index 000000000000..4290599cb37d
--- /dev/null
+++ b/test/runtime/samples/key-block-3/_config.js
@@ -0,0 +1,11 @@
+// key is not used in the template
+export default {
+ html: ``,
+ async test({ assert, component, target, window }) {
+ const div = target.querySelector('div');
+
+ component.value = 5;
+ assert.htmlEqual(target.innerHTML, ``);
+ assert.notStrictEqual(div, target.querySelector('div'));
+ }
+};
diff --git a/test/runtime/samples/key-block-3/main.svelte b/test/runtime/samples/key-block-3/main.svelte
new file mode 100644
index 000000000000..1ed185c732be
--- /dev/null
+++ b/test/runtime/samples/key-block-3/main.svelte
@@ -0,0 +1,7 @@
+
+
+{#key value}
+
+{/key}
\ No newline at end of file
diff --git a/test/runtime/samples/key-block-array-immutable/_config.js b/test/runtime/samples/key-block-array-immutable/_config.js
new file mode 100644
index 000000000000..fb94556c0ff7
--- /dev/null
+++ b/test/runtime/samples/key-block-array-immutable/_config.js
@@ -0,0 +1,15 @@
+export default {
+ html: `1
`,
+ async test({ assert, component, target, window }) {
+ let div = target.querySelector("div");
+ await component.append(2);
+ assert.htmlEqual(target.innerHTML, `1
`);
+ assert.strictEqual(div, target.querySelector("div"));
+
+ div = target.querySelector("div");
+
+ component.array = [3, 4];
+ assert.htmlEqual(target.innerHTML, `3,4
`);
+ assert.notStrictEqual(div, target.querySelector("div"));
+ }
+};
diff --git a/test/runtime/samples/key-block-array-immutable/main.svelte b/test/runtime/samples/key-block-array-immutable/main.svelte
new file mode 100644
index 000000000000..e666275af4c7
--- /dev/null
+++ b/test/runtime/samples/key-block-array-immutable/main.svelte
@@ -0,0 +1,14 @@
+
+
+
+
+{#key array}
+ {array.join(',')}
+{/key}
\ No newline at end of file
diff --git a/test/runtime/samples/key-block-array/_config.js b/test/runtime/samples/key-block-array/_config.js
new file mode 100644
index 000000000000..05d5fe9995f4
--- /dev/null
+++ b/test/runtime/samples/key-block-array/_config.js
@@ -0,0 +1,15 @@
+export default {
+ html: `1
`,
+ async test({ assert, component, target, window }) {
+ let div = target.querySelector("div");
+ await component.append(2);
+ assert.htmlEqual(target.innerHTML, `1,2
`);
+ assert.notStrictEqual(div, target.querySelector("div"));
+
+ div = target.querySelector("div");
+
+ component.array = [3, 4];
+ assert.htmlEqual(target.innerHTML, `3,4
`);
+ assert.notStrictEqual(div, target.querySelector("div"));
+ }
+};
diff --git a/test/runtime/samples/key-block-array/main.svelte b/test/runtime/samples/key-block-array/main.svelte
new file mode 100644
index 000000000000..5a4054b0430d
--- /dev/null
+++ b/test/runtime/samples/key-block-array/main.svelte
@@ -0,0 +1,12 @@
+
+
+{#key array}
+ {array.join(',')}
+{/key}
\ No newline at end of file
diff --git a/test/runtime/samples/key-block-expression-2/_config.js b/test/runtime/samples/key-block-expression-2/_config.js
new file mode 100644
index 000000000000..236c72fa3dd5
--- /dev/null
+++ b/test/runtime/samples/key-block-expression-2/_config.js
@@ -0,0 +1,18 @@
+export default {
+ html: `3
`,
+ async test({ assert, component, target, window }) {
+ const div = target.querySelector("div");
+
+ await component.mutate();
+ assert.htmlEqual(target.innerHTML, `5
`);
+ assert.strictEqual(div, target.querySelector("div"));
+
+ await component.reassign();
+ assert.htmlEqual(target.innerHTML, `7
`);
+ assert.strictEqual(div, target.querySelector("div"));
+
+ await component.changeKey();
+ assert.htmlEqual(target.innerHTML, `7
`);
+ assert.notStrictEqual(div, target.querySelector("div"));
+ }
+};
diff --git a/test/runtime/samples/key-block-expression-2/main.svelte b/test/runtime/samples/key-block-expression-2/main.svelte
new file mode 100644
index 000000000000..5525f637615f
--- /dev/null
+++ b/test/runtime/samples/key-block-expression-2/main.svelte
@@ -0,0 +1,17 @@
+
+
+{#key obj.key}
+ {obj.value}
+{/key}
\ No newline at end of file
diff --git a/test/runtime/samples/key-block-expression/_config.js b/test/runtime/samples/key-block-expression/_config.js
new file mode 100644
index 000000000000..78890988eab6
--- /dev/null
+++ b/test/runtime/samples/key-block-expression/_config.js
@@ -0,0 +1,28 @@
+export default {
+ html: `000
`,
+ async test({ assert, component, target, window }) {
+ let div = target.querySelector("div");
+ component.value = 2;
+ assert.htmlEqual(target.innerHTML, `200
`);
+ assert.notStrictEqual(div, target.querySelector("div"));
+
+ div = target.querySelector("div");
+
+ component.anotherValue = 5;
+ assert.htmlEqual(target.innerHTML, `250
`);
+ assert.notStrictEqual(div, target.querySelector("div"));
+
+ div = target.querySelector("div");
+
+ component.thirdValue = 9;
+ assert.htmlEqual(target.innerHTML, `259
`);
+ assert.strictEqual(div, target.querySelector("div"));
+
+ // make dirty while maintain the value of `value + anotherValue`
+ // should update the content, but not recreate the elements
+ await component.$set({ value: 4, anotherValue: 3 });
+
+ assert.htmlEqual(target.innerHTML, `439
`);
+ assert.strictEqual(div, target.querySelector("div"));
+ }
+};
diff --git a/test/runtime/samples/key-block-expression/main.svelte b/test/runtime/samples/key-block-expression/main.svelte
new file mode 100644
index 000000000000..dd752e8b8fdf
--- /dev/null
+++ b/test/runtime/samples/key-block-expression/main.svelte
@@ -0,0 +1,9 @@
+
+
+{#key value + anotherValue}
+ {value}{anotherValue}{thirdValue}
+{/key}
\ No newline at end of file
diff --git a/test/runtime/samples/key-block-static/_config.js b/test/runtime/samples/key-block-static/_config.js
new file mode 100644
index 000000000000..d5ea0bf6871e
--- /dev/null
+++ b/test/runtime/samples/key-block-static/_config.js
@@ -0,0 +1,9 @@
+export default {
+ html: `00
`,
+ async test({ assert, component, target, window }) {
+ const div = target.querySelector('div');
+ component.anotherValue = 2;
+ assert.htmlEqual(target.innerHTML, `02
`);
+ assert.strictEqual(div, target.querySelector('div'));
+ }
+};
diff --git a/test/runtime/samples/key-block-static/main.svelte b/test/runtime/samples/key-block-static/main.svelte
new file mode 100644
index 000000000000..e4ee6b5d7146
--- /dev/null
+++ b/test/runtime/samples/key-block-static/main.svelte
@@ -0,0 +1,8 @@
+
+
+{#key value}
+ {value}{anotherValue}
+{/key}
\ No newline at end of file
diff --git a/test/runtime/samples/key-block-transition/_config.js b/test/runtime/samples/key-block-transition/_config.js
new file mode 100644
index 000000000000..53de6b333cf0
--- /dev/null
+++ b/test/runtime/samples/key-block-transition/_config.js
@@ -0,0 +1,24 @@
+export default {
+ html: '0
',
+ async test({ assert, component, target, window, raf }) {
+ component.value = 2;
+
+ const [div1, div2] = target.querySelectorAll('div');
+
+ assert.htmlEqual(div1.outerHTML, '0
');
+ assert.htmlEqual(div2.outerHTML, '2
');
+
+ raf.tick(0);
+
+ assert.equal(div1.foo, 1);
+ assert.equal(div1.oof, 0);
+
+ assert.equal(div2.foo, 0);
+ assert.equal(div2.oof, 1);
+
+ raf.tick(200);
+
+ assert.htmlEqual(target.innerHTML, '2
');
+ assert.equal(div2, target.querySelector('div'));
+ }
+};
diff --git a/test/runtime/samples/key-block-transition/main.svelte b/test/runtime/samples/key-block-transition/main.svelte
new file mode 100644
index 000000000000..d7fb6ec02471
--- /dev/null
+++ b/test/runtime/samples/key-block-transition/main.svelte
@@ -0,0 +1,17 @@
+
+
+{#key value}
+ {value}
+{/key}
\ No newline at end of file
diff --git a/test/runtime/samples/key-block/_config.js b/test/runtime/samples/key-block/_config.js
new file mode 100644
index 000000000000..ad206c3b06da
--- /dev/null
+++ b/test/runtime/samples/key-block/_config.js
@@ -0,0 +1,17 @@
+export default {
+ html: `0
0
`,
+ async test({ assert, component, target, window }) {
+ let [div1, div2] = target.querySelectorAll('div');
+
+ component.value = 5;
+ assert.htmlEqual(target.innerHTML, `5
0
`);
+ assert.notStrictEqual(div1, target.querySelectorAll('div')[0]);
+ assert.strictEqual(div2, target.querySelectorAll('div')[1]);
+ [div1, div2] = target.querySelectorAll('div');
+
+ component.reactive = 10;
+ assert.htmlEqual(target.innerHTML, `5
10
`);
+ assert.strictEqual(div1, target.querySelectorAll('div')[0]);
+ assert.strictEqual(div2, target.querySelectorAll('div')[1]);
+ }
+};
diff --git a/test/runtime/samples/key-block/main.svelte b/test/runtime/samples/key-block/main.svelte
new file mode 100644
index 000000000000..ac3c340770b4
--- /dev/null
+++ b/test/runtime/samples/key-block/main.svelte
@@ -0,0 +1,10 @@
+
+
+{#key value}
+ {value}
+{/key}
+
+{reactive}
\ No newline at end of file