Class: Goat::DOMTools::DOMDiff

Inherits:
Object
  • Object
show all
Extended by:
Goat::DOMTools
Includes:
Goat::DOMTools, DiffTools
Defined in:
lib/goat/dom.rb

Class Method Summary collapse

Instance Method Summary collapse

Methods included from Goat::DOMTools

attrs, body, car_tag, dom_components, dom_node?, domid, expanded_dom, find_component, inject_prefixes, is_attrs?, nested_body?, normalized_tags, tag, transpose, traverse

Methods included from DiffTools

#added, #dom_node?, #lcs_is_addition?, #lcs_is_removal?, #lcs_is_replacement?, #lcs_old_and_new, #removed, #type_of

Constructor Details

#initialize(old, new) ⇒ DOMDiff

Returns a new instance of DOMDiff.



522
523
524
525
# File 'lib/goat/dom.rb', line 522

def initialize(old, new)
  @old = old
  @new = new
end

Class Method Details

.diff(old, new) ⇒ Object

the constraints:

  • arrays might be tags or they might be arrays

  • we can’t directly address textual entities; they must be addressed by their enclosing tag (i.e. <li>foo</li> -> <li>bar</li> is a change to an li tag, not a change to the contents of an li tag. Why, you ask? Here: > $(“#foo”).innerHTML “<span>blub</span>foo<span>qux</span>bar” > $(“#foo”).children

    <span>blub</span>, <span>qux</span>

    Text isn’t a thing in the DOM tree. So, in order to traverse, we have to restrict ourselves to DOM nodes, i.e. tags, not raw textual elements in the tree. I don’t know why the DOM works this way.

  • we only try to deal with changes where the parent element of the changed element has a

    unique

    ID. We could address by route through the tree, but we don’t attempt to store

    this.

  • we can’t always tell in advance where we should make a change. For example,

    :ul, [:li, [:a, => ‘hello’, [:b, ‘test’]]]

    changing to:

    :ul, [:li, [:a, => ‘hello’, [:b, ‘blub’]]]

    When we’re looking at the ul, and before we know what exactly is different below us, it may be the case that the change can be encapsulated more neatly somewhere in the subtree, but it may not – and even if the change is nice and isolated further down, there might not be a parent ID for us to hook off. This is what NoParentDOMID exceptions are used for.

  • we want to ignore artefacts from randomization of IDs. (i.e. cases where nothing has changed except the dom_XXXXXXXX ID of some element. This is pretty easy intuitively, but a bit more subtle in practice. The obvious thing to do is to use an alternate comparison function, instead of ==, but this may be slow, and would (worse) need a lot of care to make sure that your code (and outside libraries, such as Diff::LCS, which we use) never call it. Next obvious thing is to create a subclass of Hash, make all hash attributes (=> ‘dom_XXXXXXX’, :class => ‘…’) instances of it, and then redefine == on the subclass. This doesn’t work, though, because ruby’s crappy C implementation won’t dispatch to the subclass’s == – for hashes, arrays, etc., the comparison code is wired in and can’t be overridden. So instead, we have our subclass actually remove the unwanted elements, and store them in a separate @deleted ivar. The crappy part of this is that you need to manually go through the dom and stick them all back in the “actual” hash once you’ve finished diffing.

  • we *don’t* want to remove IDs that don’t change. For example, during a rerender, we preserve the ID of the rerendered-component (just not the IDs of any child components). If we deleted the IDs of these, constraint #3 would mean we’d miss a lot of potential diffs, since we’d think there were changes we couldn’t make. (I.e. think a child’s parent doesn’t have an ID when in fact it does.) One todo here is to make the differ check the @deleted ivar of a DOMAttrs instance for IDs, since they’re perfectly valid hooks to use when removing or adding components. If you do this, conflicts become much more likely though – say you have multiple changes to apply; a node whose ID you depend on may be removed.



492
493
494
# File 'lib/goat/dom.rb', line 492

def self.diff(old, new)
  d = self.new(old, new).diff
end

.dom_diff(old, new, id) ⇒ Object



510
511
512
513
514
515
516
517
518
519
520
# File 'lib/goat/dom.rb', line 510

def self.dom_diff(old, new, id)
  trees = []
  [old, new].each {|tree| trees << preproc(tree, id)}
  diff(*trees).map do |diff|
    if diff.first == :add
      diff[0..-2] + [unproc(diff[3])]
    else
      diff
    end
  end
end

.preproc(tree, id) ⇒ Object



496
497
498
499
500
501
502
503
# File 'lib/goat/dom.rb', line 496

def self.preproc(tree, id)
  DOMTools.transpose(tree) do |elt, update|
    if dom_node?(elt) && attrs(elt)
      update.call([tag(elt), DOMAttrs.from_hash(attrs(elt), id), *normalized_tags(body(elt))])
    end
    true
  end
end

.unproc(tree) ⇒ Object



505
506
507
508
# File 'lib/goat/dom.rb', line 505

def self.unproc(tree)
  tree.flatten.each{|elt| elt.reconstitute if elt.is_a?(DOMAttrs)}
  tree
end

Instance Method Details

#cmp(old, new, par) ⇒ Object



599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
# File 'lib/goat/dom.rb', line 599

def cmp(old, new, par)
  return [] if old == new

  told, tnew = type_of(old), type_of(new)
  if told != tnew
    if told == :string || tnew == :string
      raise TextualTranspose
    else
      return transpose(old, new, par)
    end
  end

  if told == :string || tnew == :string && old != new
    raise TextualTranspose
  end

  type = told

  if type == :node
    cmp_node(old, new, par)
  elsif type == :array
    cmp_array(old, new, par)
  else
    raise "Unknown thing: #{old.inspect}"
  end
end

#cmp_array(old, new, par) ⇒ Object



551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
# File 'lib/goat/dom.rb', line 551

def cmp_array(old, new, par)
  if old.size == 1 && new.size == 1
    return cmp(old.first, new.first, par)
  end

  patch = Diff::LCS.diff(old, new).flatten(1)

  left, right = patch
  if patch.size == 2 && lcs_is_replacement?(left, right)
    # if we're just replacing an element, maybe we can do it more elegantly. e.g.,
    # [:p, [:li, 'foo'], [:li, 'bar']] -> [:p, [:li, 'foo'], [:li, 'bar'], [:li, 'baz']]
    # can be represented as a replacement of the p tag itself, or the insertion of
    # [:li, 'baz']. If the patch looks like a simple replacement, we delve deeper.
    dold, dnew = lcs_old_and_new(left, right)
    old, new = dold.element, dnew.element
    begin
      return cmp(old, new, domid(old))
    rescue NoParentDOMID, TextualTranspose
      # fine fine if you hate fun we can just continue on our merry way below
    end
  end

  a, b, off = 0, 0, 0
  chgs = patch.map do |ch|
    if lcs_is_addition?(ch)
      while b < ch.position
        a += 1; b += 1; off += 1
      end
      b += 1

      mut = [added(ch.element, par, off)]
      off += 1
      mut
    elsif lcs_is_removal?(ch)
      while a < ch.position
        a += 1; b += 1; off += 1
      end
      a += 1

      [removed(ch.element, par, off)]
    else
      raise "Bad diff: #{ch.inspect}"
    end
  end

  chgs.flatten(1)
end

#cmp_node(old, new, par) ⇒ Object



535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
# File 'lib/goat/dom.rb', line 535

def cmp_node(old, new, par)
  oattrs = attrs(old)
  if tag(old) == tag(new) && \
      oattrs == attrs(new)
    begin
      cmp(body(old), body(new), oattrs ? domid(old) : nil)
    rescue NoParentDOMID => e
      transpose(old, new, par)
    rescue TextualTranspose => e
      transpose(old, new, par)
    end
  else
    transpose(old, new, par)
  end
end

#diffObject



527
528
529
# File 'lib/goat/dom.rb', line 527

def diff
  cmp(@old, @new, nil)
end

#transpose(old, new, par) ⇒ Object



531
532
533
# File 'lib/goat/dom.rb', line 531

def transpose(old, new, par)
  [removed(old, par, 0), added(new, par, 0)]
end