funicular 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Funicular
4
- VERSION = "0.5.0"
5
- end
3
+ # The version is defined once in mrblib/version.rb so that the same value
4
+ # is compiled into PicoRuby.wasm. This file only exists to keep the
5
+ # conventional `require "funicular/version"` entry point working.
6
+ require_relative "../../mrblib/version"
@@ -54,8 +54,8 @@ namespace :funicular do
54
54
  end
55
55
  end
56
56
 
57
- desc "Install Funicular debug assets, PicoRuby.wasm artifacts, and test support into a Rails app"
58
- task install: ["install:debug_assets", "install:wasm", "install:test"] do
57
+ desc "Install Funicular debug assets, the gem initializer, PicoRuby.wasm artifacts, and test support into a Rails app"
58
+ task install: ["install:debug_assets", "install:initializer", "install:wasm", "install:test"] do
59
59
  puts ""
60
60
  puts "All Funicular assets installed."
61
61
  puts ""
@@ -79,32 +79,39 @@ namespace :funicular do
79
79
  end
80
80
 
81
81
  namespace :install do
82
- desc "Install Funicular debug JS/CSS assets and the gem initializer"
82
+ desc "Install Funicular debug JS/CSS assets"
83
83
  task :debug_assets do
84
84
  require "fileutils"
85
85
 
86
- javascripts_dir = Rails.root.join("app", "assets", "javascripts")
87
- stylesheets_dir = Rails.root.join("app", "assets", "stylesheets")
88
- initializers_dir = Rails.root.join("config", "initializers")
86
+ javascripts_dir = Rails.root.join("app", "assets", "javascripts")
87
+ stylesheets_dir = Rails.root.join("app", "assets", "stylesheets")
89
88
 
90
89
  FileUtils.mkdir_p(javascripts_dir)
91
90
  FileUtils.mkdir_p(stylesheets_dir)
92
- FileUtils.mkdir_p(initializers_dir)
93
91
 
94
- source_js = File.expand_path("../funicular/assets/funicular_debug.js", __dir__)
95
- source_css = File.expand_path("../funicular/assets/funicular_debug.css", __dir__)
96
- source_initializer = File.expand_path("../funicular/assets/funicular.rb", __dir__)
92
+ source_js = File.expand_path("../funicular/assets/funicular_debug.js", __dir__)
93
+ source_css = File.expand_path("../funicular/assets/funicular_debug.css", __dir__)
97
94
 
98
- dest_js = javascripts_dir.join("funicular_debug.js")
99
- dest_css = stylesheets_dir.join("funicular_debug.css")
100
- dest_initializer = initializers_dir.join("funicular.rb")
95
+ dest_js = javascripts_dir.join("funicular_debug.js")
96
+ dest_css = stylesheets_dir.join("funicular_debug.css")
101
97
 
102
- FileUtils.cp(source_js, dest_js)
103
- FileUtils.cp(source_css, dest_css)
98
+ FileUtils.cp(source_js, dest_js)
99
+ FileUtils.cp(source_css, dest_css)
104
100
 
105
101
  puts "Installed Funicular debug assets:"
106
102
  puts " - #{dest_js}"
107
103
  puts " - #{dest_css}"
104
+ end
105
+
106
+ desc "Install the gem initializer (config/initializers/funicular.rb)"
107
+ task :initializer do
108
+ require "fileutils"
109
+
110
+ initializers_dir = Rails.root.join("config", "initializers")
111
+ FileUtils.mkdir_p(initializers_dir)
112
+
113
+ source_initializer = File.expand_path("../funicular/assets/funicular.rb", __dir__)
114
+ dest_initializer = initializers_dir.join("funicular.rb")
108
115
 
109
116
  # The initializer belongs to the application once it exists:
110
117
  # re-running the installer must never clobber its configuration
@@ -113,6 +120,7 @@ namespace :funicular do
113
120
  puts "Skipped #{dest_initializer} (exists; delete it first to reinstall the template)"
114
121
  else
115
122
  FileUtils.cp(source_initializer, dest_initializer)
123
+ puts "Installed Funicular initializer:"
116
124
  puts " - #{dest_initializer}"
117
125
  end
118
126
  end
data/minitest/ssr_test.rb CHANGED
@@ -32,6 +32,22 @@ class SSRTest < Minitest::Test
32
32
  serialize(el("p", {}, ["a & b <c>"]))
33
33
  end
34
34
 
35
+ def test_marks_empty_slots_and_seams_between_texts
36
+ assert_equal "<div><!----><b></b>a<!---->b<i></i>c</div>",
37
+ serialize(el("div", {}, [nil, el("b"), "a", "b", el("i"), "c"]))
38
+ end
39
+
40
+ def test_raw_text_elements_are_not_marked
41
+ assert_equal "<textarea>ab</textarea>", serialize(el("textarea", {}, [nil, "a", "b"]))
42
+ end
43
+
44
+ def test_doubles_the_leading_newline_the_parser_strips
45
+ assert_equal "<pre>\n\nfoo</pre>", serialize(el("pre", {}, ["\nfoo"]))
46
+ assert_equal "<textarea>\n\nfoo</textarea>", serialize(el("textarea", {}, ["\nfoo"]))
47
+ assert_equal "<pre>foo\n</pre>", serialize(el("pre", {}, ["foo\n"]))
48
+ assert_equal "<div>\nfoo</div>", serialize(el("div", {}, ["\nfoo"]))
49
+ end
50
+
35
51
  def test_escapes_attribute_values
36
52
  assert_equal '<div title="&quot;hi&quot;"></div>',
37
53
  serialize(el("div", { title: '"hi"' }))
data/mrblib/component.rb CHANGED
@@ -418,6 +418,14 @@ module Funicular
418
418
  @suspense_definitions ||= {}
419
419
  end
420
420
 
421
+ # True while patch is re-rendering this component. Callers that would
422
+ # re-enter patch from inside that render (for example a child page that
423
+ # navigates in component_mounted) must take another path, because patch
424
+ # ignores nested calls.
425
+ def updating?
426
+ @updating
427
+ end
428
+
421
429
  # Update state and trigger re-render
422
430
  def patch(new_state)
423
431
  return unless @mounted
@@ -709,8 +717,8 @@ module Funicular
709
717
  end
710
718
 
711
719
  # Recursively bind events for children
712
- if vnode.children && dom_element.children
713
- children = dom_element.children.to_a
720
+ if vnode.children
721
+ children = VDOM.child_nodes(dom_element)
714
722
  vnode.children.each_with_index do |child_vnode, index|
715
723
  if child_vnode.is_a?(VDOM::Element)
716
724
  child_element = children[index]
@@ -748,8 +756,8 @@ module Funicular
748
756
  refs_map[ref_name] = dom_element
749
757
  end
750
758
 
751
- if vnode.children && dom_element.children
752
- children = dom_element.children.to_a
759
+ if vnode.children
760
+ children = VDOM.child_nodes(dom_element)
753
761
  vnode.children.each_with_index do |child_vnode, index|
754
762
  if child_vnode.is_a?(VDOM::Element)
755
763
  child_element = children[index]
@@ -810,6 +818,20 @@ module Funicular
810
818
  __view__.component(component_class, props, &block)
811
819
  end
812
820
 
821
+ def outlet
822
+ router = @runtime&.router
823
+ depth = router&.current_layouts&.index(self.class)
824
+ return unless router && depth
825
+
826
+ nested = router.current_layouts[depth + 1]
827
+ return component(nested, preserve: true, __route__: router.current_path) if nested
828
+
829
+ route = router.current_route
830
+ return unless route
831
+
832
+ component(route[0], route[1])
833
+ end
834
+
813
835
  def form_for(model_key, options = {}, &block)
814
836
  build_form_for(__view__, model_key, options, &block)
815
837
  end
@@ -1024,9 +1046,22 @@ module Funicular
1024
1046
  return unless vnode.is_a?(VDOM::Element)
1025
1047
  return unless dom_element
1026
1048
 
1027
- dom_children = dom_element.children.to_a
1049
+ dom_children = VDOM.child_nodes(dom_element)
1050
+ skipped = 0
1051
+ previous_text = false
1028
1052
  vnode.children.each_with_index do |child, index|
1029
- child_dom = dom_children[index]
1053
+ text = VDOM.text(child)
1054
+ child_dom = dom_children[index + skipped]
1055
+ if text && child_dom && child_dom[:nodeType] == 8
1056
+ if text.empty?
1057
+ dom_element.replaceChild(dom_element[:ownerDocument].createTextNode(''), child_dom)
1058
+ elsif previous_text
1059
+ dom_element.removeChild(child_dom)
1060
+ skipped += 1
1061
+ child_dom = dom_children[index + skipped]
1062
+ end
1063
+ end
1064
+ previous_text = !text.to_s.empty?
1030
1065
  next unless child_dom
1031
1066
 
1032
1067
  if child.is_a?(VDOM::Component)
data/mrblib/debug.rb CHANGED
@@ -136,11 +136,7 @@ module Funicular
136
136
  state = component.instance_variable_get(:@state) || {}
137
137
  result = {} #: Hash[String, String]
138
138
  state.each do |key, value|
139
- begin
140
- result[key.to_s] = value.inspect
141
- rescue
142
- result[key.to_s] = "<error inspecting value>"
143
- end
139
+ result[key.to_s] = safe_inspect(value)
144
140
  end
145
141
  JSON.generate(result)
146
142
  end
@@ -159,11 +155,7 @@ module Funicular
159
155
  result[name] = "<omitted>"
160
156
  next
161
157
  end
162
- begin
163
- result[name] = component.instance_variable_get(var).inspect
164
- rescue
165
- result[name] = "<error inspecting value>"
166
- end
158
+ result[name] = safe_inspect(component.instance_variable_get(var))
167
159
  end
168
160
  JSON.generate(result)
169
161
  end
@@ -176,6 +168,58 @@ module Funicular
176
168
 
177
169
  private
178
170
 
171
+ # Bounded inspect for the DevTools inspector. A plain Object#inspect
172
+ # walks the whole object graph: a component's @runtime reaches the
173
+ # router, the mounted component and its entire VDOM tree, and that
174
+ # recursion overflows the wasm C stack in a -O0 build. Without a
175
+ # guard page the overflow silently overwrites the heap below the
176
+ # stack, which surfaces later as garbage registers and GC crashes.
177
+ # Only leaves are inspected in full; containers and objects are
178
+ # summarized past INSPECT_MAX_DEPTH.
179
+ INSPECT_MAX_DEPTH = 3
180
+ INSPECT_MAX_ITEMS = 25
181
+
182
+ def safe_inspect(value, depth = 0)
183
+ case value
184
+ when nil, true, false, Integer, Float, Symbol, String
185
+ value.inspect
186
+ when Array
187
+ return "[...#{value.size} items]" if depth >= INSPECT_MAX_DEPTH
188
+ items = value.first(INSPECT_MAX_ITEMS).map { |v| safe_inspect(v, depth + 1) }
189
+ items << "...#{value.size - INSPECT_MAX_ITEMS} more" if value.size > INSPECT_MAX_ITEMS
190
+ "[#{items.join(', ')}]"
191
+ when Hash
192
+ return "{...#{value.size} pairs}" if depth >= INSPECT_MAX_DEPTH
193
+ pairs = [] #: Array[String]
194
+ value.each do |k, v|
195
+ break if pairs.size >= INSPECT_MAX_ITEMS
196
+ pairs << "#{safe_inspect(k, depth + 1)} => #{safe_inspect(v, depth + 1)}"
197
+ end
198
+ pairs << "...#{value.size - INSPECT_MAX_ITEMS} more" if value.size > INSPECT_MAX_ITEMS
199
+ "{#{pairs.join(', ')}}"
200
+ else
201
+ safe_inspect_object(value, depth)
202
+ end
203
+ rescue => e
204
+ "<#{e.class}: #{e.message}>"
205
+ end
206
+
207
+ def safe_inspect_object(value, depth)
208
+ # JS::Object#inspect is a shallow C implementation; other BasicObject
209
+ # proxies (style accessors) raise from method_missing on any name.
210
+ return value.inspect if defined?(::JS::Object) && ::JS::Object === value
211
+ return "#<BasicObject>" unless ::Object === value
212
+ klass = value.class.to_s
213
+ return "#<#{klass}>" if depth >= INSPECT_MAX_DEPTH
214
+ ivars = value.instance_variables
215
+ return value.inspect if ivars.empty?
216
+ parts = ivars.first(INSPECT_MAX_ITEMS).map do |iv|
217
+ "#{iv}=#{safe_inspect(value.instance_variable_get(iv), depth + 1)}"
218
+ end
219
+ parts << "...#{ivars.size - INSPECT_MAX_ITEMS} more" if ivars.size > INSPECT_MAX_ITEMS
220
+ "#<#{klass} #{parts.join(', ')}>"
221
+ end
222
+
179
223
  def get_state_keys(component)
180
224
  state = component.instance_variable_get(:@state)
181
225
  return [] unless state.is_a?(Hash)
data/mrblib/differ.rb CHANGED
@@ -29,6 +29,8 @@ module Funicular
29
29
  else
30
30
  if old_node == new_node
31
31
  []
32
+ elsif old_node.is_a?(String) && new_node.is_a?(String)
33
+ [[:text, new_node]]
32
34
  else
33
35
  [[:replace, new_node, old_node]]
34
36
  end
@@ -38,11 +40,7 @@ module Funicular
38
40
  private
39
41
 
40
42
  def self.diff_text(old_node, new_node)
41
- if old_node.content != new_node.content
42
- [[:replace, new_node, old_node]]
43
- else
44
- []
45
- end
43
+ old_node.content == new_node.content ? [] : [[:text, new_node.content]]
46
44
  end
47
45
 
48
46
  def self.diff_element(old_node, new_node)
@@ -103,8 +101,8 @@ module Funicular
103
101
  # The new shape is `[[:keyed_children, ops, removes]]`, applied as
104
102
  # three phases by the patcher:
105
103
  # 1. removes (descending old_index, against the original DOM snapshot)
106
- # 2. content updates for kept children (against the snapshot, no move)
107
- # 3. inserts in ascending new_index using insertBefore on the live DOM
104
+ # 2. content updates for kept children (against the snapshot)
105
+ # 3. kept-node moves and inserts in new-index order on the live DOM
108
106
  def self.diff_children_with_keys(old_children, new_children)
109
107
  # 1. Build key map from old children
110
108
  old_key_map = {} #: Hash[untyped, [Integer, child_t]]
@@ -127,7 +125,7 @@ module Funicular
127
125
  matched_old_indices[old_index] = true
128
126
  child_patches = diff(old_child, new_child)
129
127
  ops << [:keep, old_index, new_index, child_patches]
130
- has_change = true unless child_patches.empty?
128
+ has_change = true if old_index != new_index || !child_patches.empty?
131
129
  else
132
130
  ops << [:insert, new_index, new_child]
133
131
  has_change = true
@@ -156,10 +154,12 @@ module Funicular
156
154
  # unkeyed ones) is gone from the new render and must be removed.
157
155
  # Skipping unkeyed olds here left stale nodes in the DOM, e.g. a
158
156
  # "Loading..." placeholder that never disappeared once the keyed
159
- # list it was replaced by arrived.
157
+ # list it was replaced by arrived. Raw String children are text
158
+ # nodes in the DOM (normalize_children keeps them as Strings), so
159
+ # they must be collected here as well. normalize_children never
160
+ # leaves nil in the list, so every entry is a real DOM node.
160
161
  removes = [] #: Array[[Integer, child_t]]
161
162
  old_children.each_with_index do |old_child, old_index|
162
- next unless old_child.is_a?(VNode)
163
163
  next if matched_old_indices[old_index]
164
164
  removes << [old_index, old_child]
165
165
  has_change = true
data/mrblib/funicular.rb CHANGED
@@ -15,11 +15,8 @@ rescue LoadError
15
15
  end
16
16
 
17
17
  module Funicular
18
- # Guard against redefinition: when the mrblib runtime is loaded into a
19
- # CRuby/Rails process for SSR, lib/funicular/version.rb has already defined
20
- # VERSION for the CRuby gem. In the wasm build VERSION is undefined here.
21
- VERSION = '0.5.0' unless Funicular.const_defined?(:VERSION)
22
-
18
+ # VERSION is defined in mrblib/version.rb, which is compiled into the wasm
19
+ # build alongside this file and loaded by lib/funicular/version.rb on CRuby.
23
20
  def self.version
24
21
  VERSION
25
22
  end
@@ -18,6 +18,13 @@ module Funicular
18
18
  # Props that must never be emitted as HTML attributes.
19
19
  SKIP_PROPS = %i[ref key]
20
20
 
21
+ RAW_TEXT_ELEMENTS = %w[textarea title style]
22
+
23
+ # The HTML parser drops one newline right after the start tag of these
24
+ # elements. Emit an extra newline so the text node the client VDOM
25
+ # expects survives parsing with its content intact.
26
+ NEWLINE_STRIPPING_ELEMENTS = %w[pre textarea listing]
27
+
21
28
  def self.serialize(vnode, runtime = nil)
22
29
  new(runtime).render(vnode)
23
30
  end
@@ -50,27 +57,33 @@ module Funicular
50
57
  tag = element.tag
51
58
  attrs = serialize_props(element.props)
52
59
 
53
- if VOID_ELEMENTS.include?(tag.downcase)
60
+ lower = tag.downcase
61
+ if VOID_ELEMENTS.include?(lower)
54
62
  "<#{tag}#{attrs}>"
55
63
  else
56
- "<#{tag}#{attrs}>#{render_children(element.children)}</#{tag}>"
64
+ inner = render_children(element.children, !RAW_TEXT_ELEMENTS.include?(lower))
65
+ inner = "\n#{inner}" if NEWLINE_STRIPPING_ELEMENTS.include?(lower) && inner.start_with?("\n")
66
+ "<#{tag}#{attrs}>#{inner}</#{tag}>"
57
67
  end
58
68
  end
59
69
 
60
- def render_children(children)
61
- parts = [] #: Array[String]
62
- children.each do |child|
63
- if child.is_a?(VNode)
64
- parts << render(child)
65
- elsif child.is_a?(String)
66
- parts << escape_html(child)
67
- elsif child.is_a?(Array)
68
- parts << render_children(child)
69
- elsif !child.nil?
70
- parts << escape_html(child.to_s)
70
+ # Element#children is flattened by normalize_children, so nested arrays
71
+ # need no handling here.
72
+ def render_children(children, marks)
73
+ previous_text = false
74
+ children.map do |child|
75
+ text = VDOM.text(child)
76
+ html = if text
77
+ escape_html(text)
78
+ elsif child.is_a?(VNode)
79
+ render(child)
80
+ else
81
+ ''
71
82
  end
72
- end
73
- parts.join
83
+ html = "<!---->#{html}" if marks && text && (text.empty? || previous_text)
84
+ previous_text = !text.to_s.empty?
85
+ html
86
+ end.join
74
87
  end
75
88
 
76
89
  def render_text(text)
data/mrblib/patcher.rb CHANGED
@@ -24,6 +24,8 @@ module Funicular
24
24
  # Props patches only make sense for Element nodes (text nodes have
25
25
  # no attributes); narrow before delegating.
26
26
  update_props(element, patch[1]) if element.is_a?(JS::Element)
27
+ when :text
28
+ element[:nodeValue] = patch[1].to_s
27
29
  when :update_and_rebind
28
30
  instance, internal_patches, new_vdom = patch[1], patch[2], patch[3]
29
31
  old_dom_element = instance.dom_element
@@ -31,9 +33,13 @@ module Funicular
31
33
  # Apply internal patches and get the potentially new root element
32
34
  new_dom_element = Patcher.new(@doc, instance.runtime).apply(old_dom_element, internal_patches)
33
35
 
34
- # Update the instance's reference to its root DOM element if it changed
36
+ # Update the instance's reference to its root DOM element if it
37
+ # changed. The new root must also become this apply's return
38
+ # value; otherwise a caller that stores the result (for example
39
+ # the keyed_children snapshot) keeps pointing at a detached node.
35
40
  if new_dom_element != old_dom_element && new_dom_element.is_a?(JS::Element)
36
41
  instance.dom_element = new_dom_element
42
+ result = new_dom_element
37
43
  end
38
44
 
39
45
  # Update the instance's VDOM to the new one AFTER applying patches
@@ -65,18 +71,23 @@ module Funicular
65
71
  # 1. snapshot DOM children, then remove unmatched keyed old
66
72
  # children (descending old_index so the snapshot indices
67
73
  # remain valid as removes happen).
68
- # 2. apply content updates to kept children in place. The
74
+ # 2. apply content updates to kept children. The
69
75
  # lookup is by snapshot[old_index], so updates are stable
70
- # regardless of subsequent insertions.
71
- # 3. insert new children at their new_index using
72
- # insertBefore on the live DOM. Processed in ascending
73
- # new_index order so each insertion fixes its own
74
- # position before later inserts run.
76
+ # regardless of removals.
77
+ # 3. place kept and new children at their new_index using
78
+ # insertBefore on the live DOM. Ops are already in ascending
79
+ # new_index order, so each placement fixes the next position.
80
+ #
81
+ # Phase 3 tracks the live child order in a Ruby array instead of
82
+ # re-reading childNodes per op. Every childNodes.to_a allocates a
83
+ # fresh JS::Object wrapper per node, so wrappers of the same DOM
84
+ # node never compare equal and the read itself is O(n) across the
85
+ # wasm boundary. Identity is therefore decided with equal? on the
86
+ # snapshot objects, which is both exact and free.
75
87
  ops = patch[1]
76
88
  removes = patch[2]
77
89
 
78
- child_nodes = element[:childNodes]
79
- snapshot = child_nodes.is_a?(JS::Object) ? child_nodes.to_a : [] #: Array[untyped]
90
+ snapshot = VDOM.child_nodes(element)
80
91
 
81
92
  # Phase 1: removes (descending old_index)
82
93
  sorted_removes = removes.sort { |a, b| b[0] <=> a[0] }
@@ -88,9 +99,10 @@ module Funicular
88
99
  unmount_component(old_vnode)
89
100
  parent_el = target.parentElement
90
101
  parent_el.removeChild(target) if parent_el
102
+ snapshot[old_index] = nil
91
103
  end
92
104
 
93
- # Phase 2: updates against the snapshot (no movement)
105
+ # Phase 2: updates against the snapshot
94
106
  ops.each do |op|
95
107
  next unless op[0] == :keep
96
108
  old_index = op[1]
@@ -98,31 +110,45 @@ module Funicular
98
110
  next if child_patches.empty?
99
111
  target = snapshot[old_index]
100
112
  next if target.nil?
101
- apply(target, child_patches)
113
+ snapshot[old_index] = apply(target, child_patches)
102
114
  end
103
115
 
104
- # Phase 3: inserts in ascending new_index order
116
+ # Phase 3: moves and inserts in ascending new_index order.
117
+ # `live` mirrors the DOM child order after phases 1 and 2 and is
118
+ # updated alongside every DOM mutation, so a node already sitting
119
+ # at new_index is left untouched (no detach/re-attach).
120
+ live = snapshot.compact #: Array[untyped]
105
121
  ops.each do |op|
106
- next unless op[0] == :insert
107
- new_index = op[1]
108
- new_vnode = op[2]
109
- new_node = create_element(new_vnode)
122
+ case op[0]
123
+ when :keep
124
+ new_node = snapshot[op[1]]
125
+ new_index = op[2]
126
+ when :insert
127
+ new_index = op[1]
128
+ new_node = create_element(op[2])
129
+ else
130
+ next
131
+ end
110
132
  next if new_node.nil?
111
- live_nodes = element[:childNodes]
112
- live_arr = live_nodes.is_a?(JS::Object) ? live_nodes.to_a : [] #: Array[untyped]
113
- ref = live_arr[new_index]
114
- if ref.nil?
115
- element.appendChild(new_node) if element.is_a?(JS::Element)
133
+ current = live[new_index]
134
+ next if current.equal?(new_node)
135
+ live.delete_if { |n| n.equal?(new_node) }
136
+ if new_index < live.length
137
+ live.insert(new_index, new_node)
138
+ else
139
+ live << new_node
140
+ end
141
+ next unless element.is_a?(JS::Element)
142
+ if current.nil?
143
+ element.appendChild(new_node)
116
144
  else
117
- element.insertBefore(new_node, ref) if element.is_a?(JS::Element)
145
+ element.insertBefore(new_node, current)
118
146
  end
119
147
  end
120
148
  when Integer
121
149
  child_index = patch[0]
122
150
  child_patches = patch[1]
123
- # Use childNodes instead of children to include text nodes
124
- child_nodes = element[:childNodes]
125
- children = child_nodes.is_a?(JS::Object) ? child_nodes.to_a : [] #: Array[JS::Object]
151
+ children = VDOM.child_nodes(element)
126
152
  child_element = children[child_index]
127
153
  if child_element.nil?
128
154
  # No existing child at this index - we need to create new elements
@@ -140,7 +166,7 @@ module Funicular
140
166
  end
141
167
  elsif child_element.is_a?(JS::Object)
142
168
  # Recurse into the child node. apply handles both Element and
143
- # text Node cases (the latter only meaningfully via :replace).
169
+ # text Node cases (the latter via :text or :replace).
144
170
  apply(child_element, child_patches)
145
171
  end
146
172
  end