diff --git a/src/generators/dom/Block.js b/src/generators/dom/Block.js index f905754815..83841b738d 100644 --- a/src/generators/dom/Block.js +++ b/src/generators/dom/Block.js @@ -5,10 +5,13 @@ export default class Block { constructor ( options ) { this.generator = options.generator; this.name = options.name; - this.key = options.key; this.expression = options.expression; this.context = options.context; + // for keyed each blocks + this.key = options.key; + this.first = null; + this.contexts = options.contexts; this.indexes = options.indexes; this.contextDependencies = options.contextDependencies; @@ -155,6 +158,10 @@ export default class Block { properties.addBlock( `key: ${localKey},` ); } + if ( this.first ) { + properties.addBlock( `first: ${this.first},` ); + } + if ( this.builders.mount.isEmpty() ) { properties.addBlock( `mount: ${this.generator.helper( 'noop' )},` ); } else { diff --git a/src/generators/dom/visitors/EachBlock.js b/src/generators/dom/visitors/EachBlock.js index 14c4a4297e..d30406598c 100644 --- a/src/generators/dom/visitors/EachBlock.js +++ b/src/generators/dom/visitors/EachBlock.js @@ -16,7 +16,6 @@ export default function visitEachBlock ( generator, block, state, node ) { const { snippet } = block.contextualise( node.expression ); block.builders.create.addLine( `var ${each_block_value} = ${snippet};` ); - block.builders.create.addLine( `var ${iterations} = [];` ); if ( node.key ) { keyed( generator, block, state, node, snippet, vars ); @@ -26,23 +25,12 @@ export default function visitEachBlock ( generator, block, state, node ) { const isToplevel = !state.parentNode; - if ( isToplevel ) { - block.builders.mount.addBlock( deindent` - for ( var ${i} = 0; ${i} < ${iterations}.length; ${i} += 1 ) { - ${iterations}[${i}].${mountOrIntro}( ${block.target}, null ); - } - ` ); - } - if ( node.needsAnchor ) { block.addElement( anchor, `${generator.helper( 'createComment' )}()`, state.parentNode, true ); } else if ( node.next ) { node.next.usedAsAnchor = true; } - block.builders.destroy.addBlock( - `${generator.helper( 'destroyEach' )}( ${iterations}, ${isToplevel ? 'detach' : 'false'}, 0 );` ); - if ( node.else ) { const each_block_else = generator.getUniqueName( `${each_block}_else` ); @@ -109,104 +97,205 @@ export default function visitEachBlock ( generator, block, state, node ) { } } -function keyed ( generator, block, state, node, snippet, { each_block, create_each_block, each_block_value, iterations, i, params, anchor, mountOrIntro } ) { - const fragment = block.getUniqueName( 'fragment' ); - const value = block.getUniqueName( 'value' ); +function keyed ( generator, block, state, node, snippet, { each_block, create_each_block, each_block_value, i, params, anchor, mountOrIntro } ) { const key = block.getUniqueName( 'key' ); const lookup = block.getUniqueName( `${each_block}_lookup` ); - const keys = block.getUniqueName( `${each_block}_keys` ); const iteration = block.getUniqueName( `${each_block}_iteration` ); - const _iterations = block.getUniqueName( `_${each_block}_iterations` ); + const head = block.getUniqueName( `${each_block}_head` ); + const last = block.getUniqueName( `${each_block}_last` ); + const expected = block.getUniqueName( `${each_block}_expected` ); + + if ( node.children[0] && node.children[0].type === 'Element' ) { // TODO or text/tag/raw + node._block.first = node.children[0]._state.parentNode; // TODO this is highly confusing + } else { + node._block.first = node._block.getUniqueName( 'first' ); + node._block.addElement( node._block.first, `${generator.helper( 'createComment' )}()`, null, true ); + } block.builders.create.addBlock( deindent` var ${lookup} = Object.create( null ); + var ${head}; + var ${last}; + for ( var ${i} = 0; ${i} < ${each_block_value}.length; ${i} += 1 ) { var ${key} = ${each_block_value}[${i}].${node.key}; - ${iterations}[${i}] = ${lookup}[ ${key} ] = ${create_each_block}( ${params}, ${each_block_value}, ${each_block_value}[${i}], ${i}, ${block.component}, ${key} ); - ${state.parentNode && `${iterations}[${i}].${mountOrIntro}( ${state.parentNode}, null );`} + var ${iteration} = ${lookup}[${key}] = ${create_each_block}( ${params}, ${each_block_value}, ${each_block_value}[${i}], ${i}, ${block.component}, ${key} ); + ${state.parentNode && `${iteration}.${mountOrIntro}( ${state.parentNode}, null );`} + + if ( ${last} ) ${last}.next = ${iteration}; + ${iteration}.last = ${last}; + ${last} = ${iteration}; + + if ( ${i} === 0 ) ${head} = ${iteration}; } ` ); - const consequent = node._block.hasUpdateMethod ? - deindent` - ${_iterations}[${i}] = ${lookup}[ ${key} ] = ${lookup}[ ${key} ]; - ${lookup}[ ${key} ].update( changed, ${params}, ${each_block_value}, ${each_block_value}[${i}], ${i} ); - ` : - `${_iterations}[${i}] = ${lookup}[ ${key} ] = ${lookup}[ ${key} ];`; + if ( !state.parentNode ) { + block.builders.mount.addBlock( deindent` + var ${iteration} = ${head}; + while ( ${iteration} ) { + ${iteration}.${mountOrIntro}( ${block.target}, null ); + ${iteration} = ${iteration}.next; + } + ` ); + } + const dynamic = node._block.hasUpdateMethod; const parentNode = state.parentNode || `${anchor}.parentNode`; - const hasIntros = node._block.hasIntroMethod; - - const destroy = node._block.hasOutroMethod ? - deindent` - function outro ( key ) { - ${lookup}[ key ].outro( function () { - ${lookup}[ key ].destroy( true ); - ${lookup}[ key ] = null; + let destroy; + if ( node._block.hasOutroMethod ) { + const fn = block.getUniqueName( `${each_block}_outro` ); + block.builders.create.addBlock( deindent` + function ${fn} ( iteration ) { + iteration.outro( function () { + iteration.destroy( true ); + if ( iteration.next ) iteration.next.last = iteration.last; + if ( iteration.last ) iteration.last.next = iteration.next; + ${lookup}[iteration.key] = null; }); } + ` ); - for ( var ${i} = 0; ${i} < ${iterations}.length; ${i} += 1 ) { - ${key} = ${iterations}[${i}].key; - if ( !${keys}[ ${key} ] ) outro( ${key} ); + destroy = deindent` + while ( ${expected} ) { + ${fn}( ${expected} ); + ${expected} = ${expected}.next; } - ` : - deindent` - for ( var ${i} = 0; ${i} < ${iterations}.length; ${i} += 1 ) { - var ${iteration} = ${iterations}[${i}]; - if ( !${keys}[ ${iteration}.key ] ) ${iteration}.destroy( true ); + + for ( ${i} = 0; ${i} < discard_pile.length; ${i} += 1 ) { + if ( discard_pile[${i}].discard ) { + ${fn}( discard_pile[${i}] ); + } + } + `; + } else { + const fn = block.getUniqueName( `${each_block}_destroy` ); + block.builders.create.addBlock( deindent` + function ${fn} ( iteration ) { + iteration.destroy( true ); + if ( iteration.next && iteration.next.last === iteration ) iteration.next.last = iteration.last; + if ( iteration.last && iteration.last.next === iteration ) iteration.last.next = iteration.next; + ${lookup}[iteration.key] = null; + } + ` ); + + destroy = deindent` + while ( ${expected} ) { + ${fn}( ${expected} ); + ${expected} = ${expected}.next; + } + + for ( ${i} = 0; ${i} < discard_pile.length; ${i} += 1 ) { + var ${iteration} = discard_pile[${i}]; + if ( ${iteration}.discard ) { + ${fn}( ${iteration} ); + } } `; + } block.builders.update.addBlock( deindent` var ${each_block_value} = ${snippet}; - var ${_iterations} = []; - var ${keys} = Object.create( null ); - var ${fragment} = document.createDocumentFragment(); + var ${expected} = ${head}; + var ${last}; - // create new iterations as necessary - for ( var ${i} = 0; ${i} < ${each_block_value}.length; ${i} += 1 ) { - var ${value} = ${each_block_value}[${i}]; - var ${key} = ${value}.${node.key}; - ${keys}[ ${key} ] = true; + var discard_pile = []; + + for ( ${i} = 0; ${i} < ${each_block_value}.length; ${i} += 1 ) { + var ${key} = ${each_block_value}[${i}].${node.key}; + var ${iteration} = ${lookup}[${key}]; + + ${dynamic && `if ( ${iteration} ) ${iteration}.update( changed, ${params}, ${each_block_value}, ${each_block_value}[${i}], ${i} );`} - if ( ${lookup}[ ${key} ] ) { - ${consequent} - ${hasIntros && `${_iterations}[${i}].mount( ${fragment}, null );`} + if ( ${expected} ) { + if ( ${key} === ${expected}.key ) { + ${expected} = ${expected}.next; + } else { + if ( ${iteration} ) { + // probably a deletion + do { + ${expected}.discard = true; + discard_pile.push( ${expected} ); + ${expected} = ${expected}.next; + } while ( ${expected} && ${expected}.key !== ${key} ); + + ${expected} = ${expected} && ${expected}.next; + ${iteration}.discard = false; + ${iteration}.last = ${last}; + ${iteration}.next = ${expected}; + + ${iteration}.mount( ${parentNode}, ${expected} ? ${expected}.first : ${anchor} ); + } else { + // key is being inserted + ${iteration} = ${lookup}[${key}] = ${create_each_block}( ${params}, ${each_block_value}, ${each_block_value}[${i}], ${i}, ${block.component}, ${key} ); + ${iteration}.${mountOrIntro}( ${parentNode}, ${expected}.first ); + + if ( ${expected} ) ${expected}.last = ${iteration}; + ${iteration}.next = ${expected}; + } + } } else { - ${_iterations}[${i}] = ${lookup}[ ${key} ] = ${create_each_block}( ${params}, ${each_block_value}, ${each_block_value}[${i}], ${i}, ${block.component}, ${key} ); - ${hasIntros && `${_iterations}[${i}].intro( ${fragment}, null );`} + // we're appending from this point forward + if ( ${iteration} ) { + ${iteration}.discard = false; + ${iteration}.next = null; + ${iteration}.mount( ${parentNode}, ${anchor} ); + } else { + ${iteration} = ${lookup}[${key}] = ${create_each_block}( ${params}, ${each_block_value}, ${each_block_value}[${i}], ${i}, ${block.component}, ${key} ); + ${iteration}.${mountOrIntro}( ${parentNode}, ${anchor} ); + } } - ${!hasIntros && `${_iterations}[${i}].mount( ${fragment}, null );`} + if ( ${last} ) ${last}.next = ${iteration}; + ${iteration}.last = ${last}; + ${node._block.hasIntroMethod && `${iteration}.intro( ${parentNode}, ${anchor} );`} + ${last} = ${iteration}; } - // remove old iterations + if ( ${last} ) ${last}.next = null; + ${destroy} - ${parentNode}.insertBefore( ${fragment}, ${anchor} ); + ${head} = ${lookup}[${each_block_value}[0] && ${each_block_value}[0].${node.key}]; + ` ); - ${iterations} = ${_iterations}; + block.builders.destroy.addBlock( deindent` + var ${iteration} = ${head}; + while ( ${iteration} ) { + ${iteration}.destroy( ${state.parentNode ? 'false' : 'detach'} ); + ${iteration} = ${iteration}.next; + } ` ); } function unkeyed ( generator, block, state, node, snippet, { create_each_block, each_block_value, iterations, i, params, anchor, mountOrIntro } ) { block.builders.create.addBlock( deindent` + var ${iterations} = []; + for ( var ${i} = 0; ${i} < ${each_block_value}.length; ${i} += 1 ) { ${iterations}[${i}] = ${create_each_block}( ${params}, ${each_block_value}, ${each_block_value}[${i}], ${i}, ${block.component} ); ${state.parentNode && `${iterations}[${i}].${mountOrIntro}( ${state.parentNode}, null );`} } ` ); + if ( !state.parentNode ) { + block.builders.mount.addBlock( deindent` + for ( var ${i} = 0; ${i} < ${iterations}.length; ${i} += 1 ) { + ${iterations}[${i}].${mountOrIntro}( ${block.target}, null ); + } + ` ); + } + const dependencies = block.findDependencies( node.expression ); const allDependencies = new Set( node._block.dependencies ); dependencies.forEach( dependency => { allDependencies.add( dependency ); }); + // TODO do this for keyed blocks as well const condition = Array.from( allDependencies ) .map( dependency => `'${dependency}' in changed` ) .join( ' || ' ); @@ -215,14 +304,23 @@ function unkeyed ( generator, block, state, node, snippet, { create_each_block, if ( condition !== '' ) { const forLoopBody = node._block.hasUpdateMethod ? - deindent` - if ( ${iterations}[${i}] ) { - ${iterations}[${i}].update( changed, ${params}, ${each_block_value}, ${each_block_value}[${i}], ${i} ); - } else { - ${iterations}[${i}] = ${create_each_block}( ${params}, ${each_block_value}, ${each_block_value}[${i}], ${i}, ${block.component} ); - ${iterations}[${i}].${mountOrIntro}( ${parentNode}, ${anchor} ); - } - ` : + node._block.hasIntroMethod ? + deindent` + if ( ${iterations}[${i}] ) { + ${iterations}[${i}].update( changed, ${params}, ${each_block_value}, ${each_block_value}[${i}], ${i} ); + } else { + ${iterations}[${i}] = ${create_each_block}( ${params}, ${each_block_value}, ${each_block_value}[${i}], ${i}, ${block.component} ); + } + ${iterations}[${i}].intro( ${parentNode}, ${anchor} ); + ` : + deindent` + if ( ${iterations}[${i}] ) { + ${iterations}[${i}].update( changed, ${params}, ${each_block_value}, ${each_block_value}[${i}], ${i} ); + } else { + ${iterations}[${i}] = ${create_each_block}( ${params}, ${each_block_value}, ${each_block_value}[${i}], ${i}, ${block.component} ); + ${iterations}[${i}].mount( ${parentNode}, ${anchor} ); + } + ` : deindent` ${iterations}[${i}] = ${create_each_block}( ${params}, ${each_block_value}, ${each_block_value}[${i}], ${i}, ${block.component} ); ${iterations}[${i}].${mountOrIntro}( ${parentNode}, ${anchor} ); @@ -230,9 +328,10 @@ function unkeyed ( generator, block, state, node, snippet, { create_each_block, const start = node._block.hasUpdateMethod ? '0' : `${iterations}.length`; + const outro = block.getUniqueName( 'outro' ); const destroy = node._block.hasOutroMethod ? deindent` - function outro ( i ) { + function ${outro} ( i ) { if ( ${iterations}[i] ) { ${iterations}[i].outro( function () { ${iterations}[i].destroy( true ); @@ -241,7 +340,7 @@ function unkeyed ( generator, block, state, node, snippet, { create_each_block, } } - for ( ; ${i} < ${iterations}.length; ${i} += 1 ) outro( ${i} ); + for ( ; ${i} < ${iterations}.length; ${i} += 1 ) ${outro}( ${i} ); ` : deindent` ${generator.helper( 'destroyEach' )}( ${iterations}, true, ${each_block_value}.length ); @@ -260,4 +359,8 @@ function unkeyed ( generator, block, state, node, snippet, { create_each_block, } ` ); } + + block.builders.destroy.addBlock( + `${generator.helper( 'destroyEach' )}( ${iterations}, ${state.parentNode ? 'false' : 'detach'}, 0 );` + ); } \ No newline at end of file diff --git a/src/shared/dom.js b/src/shared/dom.js index 6280783cb6..3c8bb78bc0 100644 --- a/src/shared/dom.js +++ b/src/shared/dom.js @@ -18,7 +18,7 @@ export function detachBetween ( before, after ) { export function destroyEach ( iterations, detach, start ) { for ( var i = start; i < iterations.length; i += 1 ) { - iterations[i].destroy( detach ); + if ( iterations[i] ) iterations[i].destroy( detach ); } } diff --git a/test/helpers.js b/test/helpers.js index 4318d6d364..64b4814e80 100644 --- a/test/helpers.js +++ b/test/helpers.js @@ -86,6 +86,7 @@ function cleanChildren ( node ) { previous.data = previous.data.replace( /\s{2,}/, '\n' ); node.removeChild( child ); + child = previous; } } diff --git a/test/js/samples/each-block-changed-check/expected.js b/test/js/samples/each-block-changed-check/expected.js index b31be6e9fa..128d332196 100644 --- a/test/js/samples/each-block-changed-check/expected.js +++ b/test/js/samples/each-block-changed-check/expected.js @@ -4,6 +4,7 @@ function create_main_fragment ( state, component ) { var text_1_value; var each_block_value = state.comments; + var each_block_iterations = []; for ( var i = 0; i < each_block_value.length; i += 1 ) { diff --git a/test/runtime/samples/each-block-keyed-dynamic/_config.js b/test/runtime/samples/each-block-keyed-dynamic/_config.js new file mode 100644 index 0000000000..93832a7658 --- /dev/null +++ b/test/runtime/samples/each-block-keyed-dynamic/_config.js @@ -0,0 +1,35 @@ +export default { + data: { + todos: [ + { id: 123, description: 'buy milk' }, + { id: 234, description: 'drink milk' } + ] + }, + + html: ` +

buy milk

+

drink milk

+ `, + + test ( assert, component, target ) { + const [ p1, p2 ] = target.querySelectorAll( 'p' ); + + component.set({ + todos: [ + { id: 123, description: 'buy beer' }, + { id: 234, description: 'drink beer' } + ] + }); + assert.htmlEqual( target.innerHTML, ` +

buy beer

+

drink beer

+ ` ); + + const [ p3, p4 ] = target.querySelectorAll( 'p' ); + + assert.equal( p1, p3 ); + assert.equal( p2, p4 ); + + component.destroy(); + } +}; diff --git a/test/runtime/samples/each-block-keyed-dynamic/main.html b/test/runtime/samples/each-block-keyed-dynamic/main.html new file mode 100644 index 0000000000..7d5b90a9f8 --- /dev/null +++ b/test/runtime/samples/each-block-keyed-dynamic/main.html @@ -0,0 +1,3 @@ +{{#each todos as todo @id}} +

{{todo.description}}

+{{/each}} diff --git a/test/runtime/samples/each-block-keyed-random-permute/_config.js b/test/runtime/samples/each-block-keyed-random-permute/_config.js new file mode 100644 index 0000000000..c14c4cd0f7 --- /dev/null +++ b/test/runtime/samples/each-block-keyed-random-permute/_config.js @@ -0,0 +1,48 @@ +const VALUES = Array.from( 'abcdefghijklmnopqrstuvwxyz' ); + +function toObjects ( array ) { + return array.split( '' ).map( x => ({ id: x }) ); +} + +function permute () { + const values = VALUES.slice(); + const number = Math.floor(Math.random() * VALUES.length); + const permuted = []; + for (let i = 0; i < number; i++) { + permuted.push( ...values.splice( Math.floor( Math.random() * ( number - i ) ), 1 ) ); + } + + return permuted.join( '' ); +} + +export default { + data: { + values: toObjects( 'abc' ) + }, + + html: `(a)(b)(c)`, + + test ( assert, component, target ) { + function test ( sequence ) { + component.set({ values: toObjects( sequence ) }); + assert.htmlEqual( target.innerHTML, sequence.split( '' ).map( x => `(${x})` ).join( '' ) ); + } + + // first, some fixed tests so that we can debug them + test( 'abc' ); + test( 'abcd' ); + test( 'abecd' ); + test( 'fabecd' ); + test( 'fabed' ); + test( 'beadf' ); + test( 'ghbeadf' ); + test( 'gf' ); + test( 'gc' ); + test( 'g' ); + test( '' ); + test( 'abc' ); + + // then, we party + for ( let i = 0; i < 100; i += 1 ) test( permute() ); + } +}; diff --git a/test/runtime/samples/each-block-keyed-random-permute/main.html b/test/runtime/samples/each-block-keyed-random-permute/main.html new file mode 100644 index 0000000000..a6aa4b621d --- /dev/null +++ b/test/runtime/samples/each-block-keyed-random-permute/main.html @@ -0,0 +1,3 @@ +{{#each values as value @id}} + ({{value.id}}) +{{/each}} diff --git a/test/runtime/samples/transition-js-each-block-keyed-intro-outro/_config.js b/test/runtime/samples/transition-js-each-block-keyed-intro-outro/_config.js new file mode 100644 index 0000000000..0b5c3a1d1a --- /dev/null +++ b/test/runtime/samples/transition-js-each-block-keyed-intro-outro/_config.js @@ -0,0 +1,65 @@ +export default { + data: { + things: [ + { name: 'a' }, + { name: 'b' }, + { name: 'c' } + ] + }, + + test ( assert, component, target, window, raf ) { + const divs = target.querySelectorAll( 'div' ); + divs[0].i = 0; // for debugging + divs[1].i = 1; + divs[2].i = 2; + + assert.equal( divs[0].foo, 0 ); + assert.equal( divs[1].foo, 0 ); + assert.equal( divs[2].foo, 0 ); + + raf.tick( 100 ); + assert.equal( divs[0].foo, 1 ); + assert.equal( divs[1].foo, 1 ); + assert.equal( divs[2].foo, 1 ); + + component.set({ + things: [ + { name: 'a' }, + { name: 'c' } + ] + }); + + const divs2 = target.querySelectorAll( 'div' ); + assert.strictEqual( divs[0], divs2[0] ); + assert.strictEqual( divs[1], divs2[1] ); + assert.strictEqual( divs[2], divs2[2] ); + + raf.tick( 150 ); + assert.equal( divs[0].foo, 1 ); + assert.equal( divs[1].foo, 0.5 ); + assert.equal( divs[2].foo, 1 ); + + component.set({ + things: [ + { name: 'a' }, + { name: 'b' }, + { name: 'c' } + ] + }); + + raf.tick( 175 ); + assert.equal( divs[0].foo, 1 ); + assert.equal( divs[1].foo, 0.75 ); + assert.equal( divs[2].foo, 1 ); + + raf.tick( 225 ); + const divs3 = target.querySelectorAll( 'div' ); + assert.strictEqual( divs[0], divs3[0] ); + assert.strictEqual( divs[1], divs3[1] ); + assert.strictEqual( divs[2], divs3[2] ); + + assert.equal( divs[0].foo, 1 ); + assert.equal( divs[1].foo, 1 ); + assert.equal( divs[2].foo, 1 ); + } +}; \ No newline at end of file diff --git a/test/runtime/samples/transition-js-each-block-keyed-intro-outro/main.html b/test/runtime/samples/transition-js-each-block-keyed-intro-outro/main.html new file mode 100644 index 0000000000..c755bb12e6 --- /dev/null +++ b/test/runtime/samples/transition-js-each-block-keyed-intro-outro/main.html @@ -0,0 +1,18 @@ +{{#each things as thing @name}} +
{{thing.name}}
+{{/each}} + + \ No newline at end of file diff --git a/test/runtime/samples/transition-js-each-block-keyed-outro/_config.js b/test/runtime/samples/transition-js-each-block-keyed-outro/_config.js index d40e9a3a06..567a671d2c 100644 --- a/test/runtime/samples/transition-js-each-block-keyed-outro/_config.js +++ b/test/runtime/samples/transition-js-each-block-keyed-outro/_config.js @@ -17,6 +17,11 @@ export default { ] }); + const divs2 = target.querySelectorAll( 'div' ); + assert.strictEqual( divs[0], divs2[0] ); + assert.strictEqual( divs[1], divs2[1] ); + assert.strictEqual( divs[2], divs2[2] ); + raf.tick( 50 ); assert.equal( divs[0].foo, undefined ); assert.equal( divs[1].foo, 0.5 );