funicular 0.5.0 → 0.5.1

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/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
@@ -103,8 +103,8 @@ module Funicular
103
103
  # The new shape is `[[:keyed_children, ops, removes]]`, applied as
104
104
  # three phases by the patcher:
105
105
  # 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
106
+ # 2. content updates for kept children (against the snapshot)
107
+ # 3. kept-node moves and inserts in new-index order on the live DOM
108
108
  def self.diff_children_with_keys(old_children, new_children)
109
109
  # 1. Build key map from old children
110
110
  old_key_map = {} #: Hash[untyped, [Integer, child_t]]
@@ -127,7 +127,7 @@ module Funicular
127
127
  matched_old_indices[old_index] = true
128
128
  child_patches = diff(old_child, new_child)
129
129
  ops << [:keep, old_index, new_index, child_patches]
130
- has_change = true unless child_patches.empty?
130
+ has_change = true if old_index != new_index || !child_patches.empty?
131
131
  else
132
132
  ops << [:insert, new_index, new_child]
133
133
  has_change = true
@@ -156,10 +156,12 @@ module Funicular
156
156
  # unkeyed ones) is gone from the new render and must be removed.
157
157
  # Skipping unkeyed olds here left stale nodes in the DOM, e.g. a
158
158
  # "Loading..." placeholder that never disappeared once the keyed
159
- # list it was replaced by arrived.
159
+ # list it was replaced by arrived. Raw String children are text
160
+ # nodes in the DOM (normalize_children keeps them as Strings), so
161
+ # they must be collected here as well; only nil slots are skipped.
160
162
  removes = [] #: Array[[Integer, child_t]]
161
163
  old_children.each_with_index do |old_child, old_index|
162
- next unless old_child.is_a?(VNode)
164
+ next if old_child.nil?
163
165
  next if matched_old_indices[old_index]
164
166
  removes << [old_index, old_child]
165
167
  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
data/mrblib/patcher.rb CHANGED
@@ -31,9 +31,13 @@ module Funicular
31
31
  # Apply internal patches and get the potentially new root element
32
32
  new_dom_element = Patcher.new(@doc, instance.runtime).apply(old_dom_element, internal_patches)
33
33
 
34
- # Update the instance's reference to its root DOM element if it changed
34
+ # Update the instance's reference to its root DOM element if it
35
+ # changed. The new root must also become this apply's return
36
+ # value; otherwise a caller that stores the result (for example
37
+ # the keyed_children snapshot) keeps pointing at a detached node.
35
38
  if new_dom_element != old_dom_element && new_dom_element.is_a?(JS::Element)
36
39
  instance.dom_element = new_dom_element
40
+ result = new_dom_element
37
41
  end
38
42
 
39
43
  # Update the instance's VDOM to the new one AFTER applying patches
@@ -65,18 +69,23 @@ module Funicular
65
69
  # 1. snapshot DOM children, then remove unmatched keyed old
66
70
  # children (descending old_index so the snapshot indices
67
71
  # remain valid as removes happen).
68
- # 2. apply content updates to kept children in place. The
72
+ # 2. apply content updates to kept children. The
69
73
  # 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.
74
+ # regardless of removals.
75
+ # 3. place kept and new children at their new_index using
76
+ # insertBefore on the live DOM. Ops are already in ascending
77
+ # new_index order, so each placement fixes the next position.
78
+ #
79
+ # Phase 3 tracks the live child order in a Ruby array instead of
80
+ # re-reading childNodes per op. Every childNodes.to_a allocates a
81
+ # fresh JS::Object wrapper per node, so wrappers of the same DOM
82
+ # node never compare equal and the read itself is O(n) across the
83
+ # wasm boundary. Identity is therefore decided with equal? on the
84
+ # snapshot objects, which is both exact and free.
75
85
  ops = patch[1]
76
86
  removes = patch[2]
77
87
 
78
- child_nodes = element[:childNodes]
79
- snapshot = child_nodes.is_a?(JS::Object) ? child_nodes.to_a : [] #: Array[untyped]
88
+ snapshot = child_nodes_array(element)
80
89
 
81
90
  # Phase 1: removes (descending old_index)
82
91
  sorted_removes = removes.sort { |a, b| b[0] <=> a[0] }
@@ -88,9 +97,10 @@ module Funicular
88
97
  unmount_component(old_vnode)
89
98
  parent_el = target.parentElement
90
99
  parent_el.removeChild(target) if parent_el
100
+ snapshot[old_index] = nil
91
101
  end
92
102
 
93
- # Phase 2: updates against the snapshot (no movement)
103
+ # Phase 2: updates against the snapshot
94
104
  ops.each do |op|
95
105
  next unless op[0] == :keep
96
106
  old_index = op[1]
@@ -98,31 +108,45 @@ module Funicular
98
108
  next if child_patches.empty?
99
109
  target = snapshot[old_index]
100
110
  next if target.nil?
101
- apply(target, child_patches)
111
+ snapshot[old_index] = apply(target, child_patches)
102
112
  end
103
113
 
104
- # Phase 3: inserts in ascending new_index order
114
+ # Phase 3: moves and inserts in ascending new_index order.
115
+ # `live` mirrors the DOM child order after phases 1 and 2 and is
116
+ # updated alongside every DOM mutation, so a node already sitting
117
+ # at new_index is left untouched (no detach/re-attach).
118
+ live = snapshot.compact #: Array[untyped]
105
119
  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)
120
+ case op[0]
121
+ when :keep
122
+ new_node = snapshot[op[1]]
123
+ new_index = op[2]
124
+ when :insert
125
+ new_index = op[1]
126
+ new_node = create_element(op[2])
127
+ else
128
+ next
129
+ end
110
130
  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)
131
+ current = live[new_index]
132
+ next if current.equal?(new_node)
133
+ live.delete_if { |n| n.equal?(new_node) }
134
+ if new_index < live.length
135
+ live.insert(new_index, new_node)
116
136
  else
117
- element.insertBefore(new_node, ref) if element.is_a?(JS::Element)
137
+ live << new_node
138
+ end
139
+ next unless element.is_a?(JS::Element)
140
+ if current.nil?
141
+ element.appendChild(new_node)
142
+ else
143
+ element.insertBefore(new_node, current)
118
144
  end
119
145
  end
120
146
  when Integer
121
147
  child_index = patch[0]
122
148
  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]
149
+ children = child_nodes_array(element)
126
150
  child_element = children[child_index]
127
151
  if child_element.nil?
128
152
  # No existing child at this index - we need to create new elements
@@ -150,6 +174,14 @@ module Funicular
150
174
 
151
175
  private
152
176
 
177
+ # childNodes (not children) so that text nodes are included. Each call
178
+ # crosses the wasm boundary once per child, so callers should read it
179
+ # once and work on the returned array.
180
+ def child_nodes_array(element)
181
+ child_nodes = element[:childNodes]
182
+ child_nodes.is_a?(JS::Object) ? child_nodes.to_a : [] #: Array[untyped]
183
+ end
184
+
153
185
  def unmount_component(vnode)
154
186
  return unless vnode.is_a?(VDOM::Component) && vnode.instance
155
187
  vnode.instance.unmount
data/mrblib/vdom.rb CHANGED
@@ -100,6 +100,11 @@ module Funicular
100
100
  @children = normalize_children(children || [])
101
101
  end
102
102
 
103
+ def ==(other)
104
+ return false unless other.is_a?(Element)
105
+ @tag == other.tag && @props == other.props && @children == other.children
106
+ end
107
+
103
108
  private
104
109
 
105
110
  def normalize_children(children)
@@ -124,11 +129,6 @@ module Funicular
124
129
  end
125
130
  result
126
131
  end
127
-
128
- def ==(other)
129
- return false unless other.is_a?(Element)
130
- @tag == other.tag && @props == other.props && @children == other.children
131
- end
132
132
  end
133
133
 
134
134
  class Text < VNode
data/mrblib/version.rb ADDED
@@ -0,0 +1,11 @@
1
+ # Single source of truth for the Funicular version.
2
+ #
3
+ # This file lives in mrblib so that it is compiled into PicoRuby.wasm along
4
+ # with the rest of the runtime. The CRuby gem reuses it via
5
+ # lib/funicular/version.rb, so the version is defined in exactly one place.
6
+ #
7
+ # Keep this file free of any dependency: it must evaluate standalone in
8
+ # both PicoRuby and CRuby, regardless of load order.
9
+ module Funicular
10
+ VERSION = '0.5.1'
11
+ end
data/sig/debug.rbs CHANGED
@@ -20,7 +20,12 @@ module Funicular
20
20
  def self.last_error: () -> Hash[Symbol, untyped]?
21
21
  def self.error_count: () -> Integer
22
22
 
23
+ INSPECT_MAX_DEPTH: Integer
24
+ INSPECT_MAX_ITEMS: Integer
25
+
23
26
  private def self.error_registry: () -> Array[Hash[Symbol, untyped]]
27
+ private def self.safe_inspect: (untyped value, ?Integer depth) -> String
28
+ private def self.safe_inspect_object: (untyped value, Integer depth) -> String
24
29
  private def self.get_state_keys: (Funicular::Component) -> Array[String]
25
30
  private def self.get_child_ids: (Funicular::Component) -> Array[Integer]
26
31
  private def self.collect_direct_children: (Funicular::VDOM::VNode, Array[Funicular::Component]) -> void
data/sig/patcher.rbs CHANGED
@@ -9,6 +9,7 @@ module Funicular
9
9
  def apply: (JS::Object element, Array[patch_t] patches) -> JS::Object
10
10
 
11
11
  # Accepts JS::Object since callers narrow via is_a?(JS::Element) at runtime
12
+ private def child_nodes_array: (JS::Object element) -> Array[untyped]
12
13
  private def update_props: (JS::Object element, Hash[Symbol, String?] props_patch) -> void
13
14
  private def create_element: (untyped vnode) -> JS::Object
14
15
  private def unmount_component: (VDOM::VNode vnode) -> void
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: funicular
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.5.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - HASUMI Hitoshi
@@ -156,6 +156,7 @@ files:
156
156
  - mrblib/store_singleton.rb
157
157
  - mrblib/styles.rb
158
158
  - mrblib/vdom.rb
159
+ - mrblib/version.rb
159
160
  - mrblib/view_context.rb
160
161
  - sig/cable.rbs
161
162
  - sig/component.rbs
@@ -223,7 +224,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
223
224
  - !ruby/object:Gem::Version
224
225
  version: '0'
225
226
  requirements: []
226
- rubygems_version: 4.0.10
227
+ rubygems_version: 4.0.20
227
228
  specification_version: 4
228
229
  summary: Rails plugin for client-side Ruby development with mruby
229
230
  test_files: []