diff --git a/src/compiler/compile/nodes/WithBlock.ts b/src/compiler/compile/nodes/WithBlock.ts new file mode 100644 index 000000000000..45b700f797a8 --- /dev/null +++ b/src/compiler/compile/nodes/WithBlock.ts @@ -0,0 +1,38 @@ +import Expression from './shared/Expression'; +import map_children from './shared/map_children'; +import TemplateScope from './shared/TemplateScope'; +import AbstractBlock from './shared/AbstractBlock'; +import { Context, unpack_destructuring } from './shared/Context'; +import { Node } from 'estree'; + +export default class WithBlock extends AbstractBlock { + type: 'WithBlock'; + + expression: Expression; + context_node: Node; + + context: string; + scope: TemplateScope; + contexts: Context[]; + has_binding = false; + + constructor(component, parent, scope, info) { + super(component, parent, scope, info); + + this.expression = new Expression(component, this, scope, info.expression); + this.context = info.context.name || 'with'; // TODO this is used to facilitate binding; currently fails with destructuring + this.context_node = info.context; + this.scope = scope.child(); + + this.contexts = []; + unpack_destructuring(this.contexts, info.context, node => node); + + this.contexts.forEach(context => { + this.scope.add(context.key.name, this.expression.dependencies, this); + }); + + this.children = map_children(component, this, 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..193dac344669 100644 --- a/src/compiler/compile/nodes/interfaces.ts +++ b/src/compiler/compile/nodes/interfaces.ts @@ -29,6 +29,7 @@ import ThenBlock from './ThenBlock'; import Title from './Title'; import Transition from './Transition'; import Window from './Window'; +import WithBlock from './WithBlock'; // note: to write less types each of types in union below should have type defined as literal // https://www.typescriptlang.org/docs/handbook/advanced-types.html#discriminated-unions @@ -61,4 +62,5 @@ export type INode = Action | ThenBlock | Title | Transition -| Window; +| Window +| WithBlock; diff --git a/src/compiler/compile/nodes/shared/map_children.ts b/src/compiler/compile/nodes/shared/map_children.ts index dcdc52f86d16..62ebe7643fc8 100644 --- a/src/compiler/compile/nodes/shared/map_children.ts +++ b/src/compiler/compile/nodes/shared/map_children.ts @@ -14,6 +14,7 @@ import Slot from '../Slot'; import Text from '../Text'; import Title from '../Title'; import Window from '../Window'; +import WithBlock from '../WithBlock'; import { TemplateNode } from '../../../interfaces'; export type Children = ReturnType; @@ -36,6 +37,7 @@ function get_constructor(type) { case 'Text': return Text; case 'Title': return Title; case 'Window': return Window; + case 'WithBlock': return WithBlock; default: throw new Error(`Not implemented: ${type}`); } } diff --git a/src/compiler/compile/render_dom/Block.ts b/src/compiler/compile/render_dom/Block.ts index b29b4d4afd95..f8550a0dc7a6 100644 --- a/src/compiler/compile/render_dom/Block.ts +++ b/src/compiler/compile/render_dom/Block.ts @@ -13,10 +13,10 @@ export interface BlockOptions { key?: Identifier; bindings?: Map Node; }>; dependencies?: Set; @@ -38,10 +38,10 @@ export default class Block { bindings: Map Node; }>; diff --git a/src/compiler/compile/render_dom/wrappers/Element/Binding.ts b/src/compiler/compile/render_dom/wrappers/Element/Binding.ts index ac5732eae7b0..f3aafe6761c2 100644 --- a/src/compiler/compile/render_dom/wrappers/Element/Binding.ts +++ b/src/compiler/compile/render_dom/wrappers/Element/Binding.ts @@ -280,10 +280,13 @@ function get_event_handler( const { object, property, modifier, store } = context; if (lhs.type === 'Identifier') { - lhs = modifier(x`${object}[${property}]`); - contextual_dependencies.add(object.name); - contextual_dependencies.add(property.name); + if (property === undefined) { + lhs = modifier(object); + } else { + lhs = modifier(x`${object}[${property}]`); + contextual_dependencies.add(property.name); + } } if (store) { diff --git a/src/compiler/compile/render_dom/wrappers/Fragment.ts b/src/compiler/compile/render_dom/wrappers/Fragment.ts index a0984b69b920..248356b6dea5 100644 --- a/src/compiler/compile/render_dom/wrappers/Fragment.ts +++ b/src/compiler/compile/render_dom/wrappers/Fragment.ts @@ -13,6 +13,7 @@ import Slot from './Slot'; import Text from './Text'; import Title from './Title'; import Window from './Window'; +import WithBlock from './WithBlock'; import { INode } from '../../nodes/interfaces'; import Renderer from '../Renderer'; import Block from '../Block'; @@ -36,7 +37,8 @@ const wrappers = { Slot, Text, Title, - Window + Window, + WithBlock }; function trimmable_at(child: INode, next_sibling: Wrapper): boolean { diff --git a/src/compiler/compile/render_dom/wrappers/InlineComponent/index.ts b/src/compiler/compile/render_dom/wrappers/InlineComponent/index.ts index 00f803bbbd48..9115ec3011c0 100644 --- a/src/compiler/compile/render_dom/wrappers/InlineComponent/index.ts +++ b/src/compiler/compile/render_dom/wrappers/InlineComponent/index.ts @@ -346,7 +346,9 @@ export default class InlineComponentWrapper extends Wrapper { const { name } = binding.expression.node; const { object, property, snippet } = block.bindings.get(name); lhs = snippet; - contextual_dependencies.push(object.name, property.name); + contextual_dependencies.push(object.name); + if (property !== undefined) + contextual_dependencies.push(property.name); } const params = [x`#value`]; diff --git a/src/compiler/compile/render_dom/wrappers/WithBlock.ts b/src/compiler/compile/render_dom/wrappers/WithBlock.ts new file mode 100644 index 000000000000..be2a6253ab3e --- /dev/null +++ b/src/compiler/compile/render_dom/wrappers/WithBlock.ts @@ -0,0 +1,183 @@ +import Renderer from '../Renderer'; +import Block from '../Block'; +import Wrapper from './shared/Wrapper'; +import create_debugging_comment from './shared/create_debugging_comment'; +import FragmentWrapper from './Fragment'; +import { b, x } from 'code-red'; +import WithBlock from '../../nodes/WithBlock'; +import { Node, Identifier } from 'estree'; + +export default class WithBlockWrapper extends Wrapper { + block: Block; + node: WithBlock; + fragment: FragmentWrapper; + vars: { + create_with_block: Identifier; + with_block_value: Identifier; + with_block: Identifier; + get_with_context: Identifier; + } + + dependencies: Set; + + var: Identifier = { type: 'Identifier', name: 'with' }; + + constructor( + renderer: Renderer, + block: Block, + parent: Wrapper, + node: WithBlock, + strip_whitespace: boolean, + next_sibling: Wrapper + ) { + super(renderer, block, parent, node); + this.cannot_use_innerhtml(); + this.not_static_content(); + + block.add_dependencies(node.expression.dependencies); + + this.node.contexts.forEach(context => { + renderer.add_to_context(context.key.name, true); + }); + + this.block = block.child({ + comment: create_debugging_comment(this.node, this.renderer.component), + name: renderer.component.get_unique_name('create_with_block'), + type: 'with', + // @ts-ignore todo: probably error + key: node.key as string, + + bindings: new Map(block.bindings) + }); + + const with_block_value = renderer.component.get_unique_name(`${this.var.name}_value`); + renderer.add_to_context(with_block_value.name, true); + + this.vars = { + create_with_block: this.block.name, + with_block_value, + with_block: block.get_unique_name(`${this.var.name}_block`), + get_with_context: renderer.component.get_unique_name(`get_${this.var.name}_context`), + }; + + const store = + node.expression.node.type === 'Identifier' && + node.expression.node.name[0] === '$' + ? node.expression.node.name.slice(1) + : null; + + node.contexts.forEach(prop => { + this.block.bindings.set(prop.key.name, { + object: with_block_value, + modifier: prop.modifier, + snippet: prop.modifier(x`${with_block_value}` as Node), + store + }); + }); + + renderer.blocks.push(this.block); + + this.fragment = new FragmentWrapper(renderer, this.block, node.children, this, strip_whitespace, next_sibling); + + block.add_dependencies(this.block.dependencies); + } + + render(block: Block, parent_node: Identifier, parent_nodes: Identifier) { + if (this.fragment.nodes.length === 0) return; + + const { renderer } = this; + const { + create_with_block, + with_block, + with_block_value, + get_with_context + } = this.vars; + + const needs_anchor = this.next + ? !this.next.is_dom_node() : + !parent_node || !this.parent.is_dom_node(); + + const context_props = this.node.contexts.map(prop => b`child_ctx[${renderer.context_lookup.get(prop.key.name).index}] = ${prop.modifier(x`value`)};`); + if (this.node.has_binding) context_props.push(b`child_ctx[${renderer.context_lookup.get(with_block_value.name).index}] = value;`); + + const snippet = this.node.expression.manipulate(block); + block.chunks.init.push(b`let ${with_block_value} = ${snippet};`); + + renderer.blocks.push(b` + function ${get_with_context}(#ctx, value) { + const child_ctx = #ctx.slice(); + ${context_props} + return child_ctx; + } + `); + + const initial_anchor_node: Identifier = { type: 'Identifier', name: parent_node ? 'null' : '#anchor' }; + const initial_mount_node: Identifier = parent_node || { type: 'Identifier', name: '#target' }; + const update_anchor_node = needs_anchor + ? block.get_unique_name(`${this.var.name}_anchor`) + : (this.next && this.next.var) || { type: 'Identifier', name: 'null' }; + const update_mount_node: Identifier = this.get_update_mount_node((update_anchor_node as Identifier)); + + const all_dependencies = new Set(this.block.dependencies); // TODO should be dynamic deps only + this.node.expression.dynamic_dependencies().forEach((dependency: string) => { + all_dependencies.add(dependency); + }); + this.dependencies = all_dependencies; + + block.chunks.init.push(b` + let ${with_block} = ${create_with_block}(${get_with_context}(#ctx, ${with_block_value})); + `); + + block.chunks.create.push(b` + ${with_block}.c(); + `); + + if (parent_nodes && this.renderer.options.hydratable) { + block.chunks.claim.push(b` + ${with_block}.l(${parent_nodes}); + `); + } + + block.chunks.mount.push(b` + ${with_block}.m(${initial_mount_node}, ${initial_anchor_node}); + `); + + if (this.dependencies.size) { + const update = this.block.has_update_method + ? b` + if (${with_block}) { + ${with_block}.p(child_ctx, #dirty); + } else { + ${with_block} = ${create_with_block}(child_ctx); + ${with_block}.c(); + ${with_block}.m(${update_mount_node}, ${update_anchor_node}); + }` + : b` + if (!${with_block}) { + ${with_block} = ${create_with_block}(child_ctx); + ${with_block}.c(); + ${with_block}.m(${update_mount_node}, ${update_anchor_node}); + }`; + block.chunks.update.push(b` + if (${block.renderer.dirty(Array.from(all_dependencies))}) { + ${with_block_value} = ${snippet}; + const child_ctx = ${get_with_context}(#ctx, ${with_block_value}); + ${update} + } + `); + } + + block.chunks.destroy.push(b`${with_block}.d(detaching);`); + + if (needs_anchor) { + block.add_element( + update_anchor_node as Identifier, + x`@empty()`, + parent_nodes && x`@empty()`, + parent_node + ); + } + + this.fragment.render(this.block, null, x`#nodes` as Identifier); + } +} diff --git a/src/compiler/compile/render_ssr/Renderer.ts b/src/compiler/compile/render_ssr/Renderer.ts index fb9216327c68..d2558bceeec5 100644 --- a/src/compiler/compile/render_ssr/Renderer.ts +++ b/src/compiler/compile/render_ssr/Renderer.ts @@ -11,6 +11,7 @@ import Slot from './handlers/Slot'; import Tag from './handlers/Tag'; import Text from './handlers/Text'; import Title from './handlers/Title'; +import WithBlock from './handlers/WithBlock'; import { AppendTarget, CompileOptions } from '../../interfaces'; import { INode } from '../nodes/interfaces'; import { Expression, TemplateLiteral, Identifier } from 'estree'; @@ -36,7 +37,8 @@ const handlers: Record = { Slot, Text, Title, - Window: noop + Window: noop, + WithBlock }; export interface RenderOptions extends CompileOptions{ diff --git a/src/compiler/compile/render_ssr/handlers/WithBlock.ts b/src/compiler/compile/render_ssr/handlers/WithBlock.ts new file mode 100644 index 000000000000..b7b3c98991bd --- /dev/null +++ b/src/compiler/compile/render_ssr/handlers/WithBlock.ts @@ -0,0 +1,12 @@ +import Renderer, { RenderOptions } from '../Renderer'; +import WithBlock from '../../nodes/WithBlock'; +import { x } from 'code-red'; + +export default function(node: WithBlock, renderer: Renderer, options: RenderOptions) { + const args = [node.context_node]; + + renderer.push(); + renderer.render(node.children, options); + const result = renderer.pop(); + renderer.add_expression(x`((${args}) => ${result})(${node.expression.node})`); +} diff --git a/src/compiler/parse/state/mustache.ts b/src/compiler/parse/state/mustache.ts index 4e1d5c5fd4cc..5d9b13385287 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 {/with} 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 === 'WithBlock') { + expected = 'with'; } else { parser.error({ code: `unexpected-block-close`, @@ -212,7 +214,7 @@ export default function mustache(parser: Parser) { await_block[is_then ? 'then' : 'catch'] = new_block; parser.stack.push(new_block); } else if (parser.eat('#')) { - // {#if foo}, {#each foo} or {#await foo} + // {#if foo}, {#each foo}, {#await foo}, or {#with foo} let type; if (parser.eat('if')) { @@ -221,10 +223,12 @@ export default function mustache(parser: Parser) { type = 'EachBlock'; } else if (parser.eat('await')) { type = 'AwaitBlock'; + } else if (parser.eat('with')) { + type = 'WithBlock'; } else { parser.error({ code: `expected-block-type`, - message: `Expected if, each or await` + message: `Expected if, each, await, or with` }); } @@ -272,15 +276,17 @@ export default function mustache(parser: Parser) { parser.allow_whitespace(); - // {#each} blocks must declare a context – {#each list as item} - if (type === 'EachBlock') { + // {#each} and {#with} blocks must declare a context – {#each list as item} + if (type === 'EachBlock' || type === 'WithBlock') { parser.eat('as', true); parser.require_whitespace(); block.context = read_context(parser); parser.allow_whitespace(); + } + if (type == 'EachBlock') { if (parser.eat(',')) { parser.allow_whitespace(); block.index = parser.read_identifier(); diff --git a/test/runtime/samples/with-block-destructured-object-binding/_config.js b/test/runtime/samples/with-block-destructured-object-binding/_config.js new file mode 100644 index 000000000000..436b71cfaff5 --- /dev/null +++ b/test/runtime/samples/with-block-destructured-object-binding/_config.js @@ -0,0 +1,45 @@ +export default { + props: { + people: { name: { first: 'Doctor', last: 'Who' } }, + }, + + html: ` + + +

Doctor Who

+ `, + + ssrHtml: ` + + +

Doctor Who

+ `, + + async test({ assert, component, target, window }) { + const inputs = target.querySelectorAll('input'); + + inputs[1].value = 'Oz'; + await inputs[1].dispatchEvent(new window.Event('input')); + + const { people } = component; + + assert.deepEqual(people, { + name: { first: 'Doctor', last: 'Oz' } + }); + + assert.htmlEqual(target.innerHTML, ` + + +

Doctor Oz

+ `); + + people.name.first = 'Frank'; + component.people = people; + + assert.htmlEqual(target.innerHTML, ` + + +

Frank Oz

+ `); + }, +}; diff --git a/test/runtime/samples/with-block-destructured-object-binding/main.svelte b/test/runtime/samples/with-block-destructured-object-binding/main.svelte new file mode 100644 index 000000000000..152c28cbda77 --- /dev/null +++ b/test/runtime/samples/with-block-destructured-object-binding/main.svelte @@ -0,0 +1,9 @@ + + +{#with people as { name: { first: f, last: l } } } + + +

{f} {l}

+{/with} diff --git a/test/runtime/samples/with-block/_config.js b/test/runtime/samples/with-block/_config.js new file mode 100644 index 000000000000..827d1af9b38a --- /dev/null +++ b/test/runtime/samples/with-block/_config.js @@ -0,0 +1,16 @@ +export default { + html: ` + + `, + + async test({ assert, target, window, }) { + const btn = target.querySelector('button'); + const clickEvent = new window.MouseEvent('click'); + + await btn.dispatchEvent(clickEvent); + + assert.htmlEqual(target.innerHTML, ` + + `); + } +}; diff --git a/test/runtime/samples/with-block/main.svelte b/test/runtime/samples/with-block/main.svelte new file mode 100644 index 000000000000..c9dc4249fb2f --- /dev/null +++ b/test/runtime/samples/with-block/main.svelte @@ -0,0 +1,7 @@ + + +{#with a as b } + +{/with}