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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +77 -0
- data/lib/funicular/testing/node_runner.mjs +5 -1
- data/lib/funicular/vendor/mrbc/VERSION +1 -1
- data/lib/funicular/vendor/mrbc/mrbc.js +13 -9
- data/lib/funicular/vendor/mrbc/mrbc.wasm +0 -0
- data/lib/funicular/vendor/picoruby/VERSION +1 -1
- data/lib/funicular/vendor/picoruby/debug/picoruby.js +80 -15
- data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby/dist/picoruby.js +1 -1
- data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby-test-node/VERSION +1 -1
- data/lib/funicular/vendor/picoruby-test-node/picoruby.js +1 -1
- data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
- data/lib/funicular/version.rb +4 -3
- data/lib/tasks/funicular.rake +23 -15
- data/minitest/ssr_test.rb +16 -0
- data/mrblib/component.rb +41 -6
- data/mrblib/debug.rb +54 -10
- data/mrblib/differ.rb +10 -10
- data/mrblib/funicular.rb +2 -5
- data/mrblib/html_serializer.rb +28 -15
- data/mrblib/patcher.rb +52 -26
- data/mrblib/router.rb +87 -7
- data/mrblib/vdom.rb +17 -7
- data/mrblib/version.rb +11 -0
- data/sig/component.rbs +2 -0
- data/sig/debug.rbs +5 -0
- data/sig/html_serializer.rbs +3 -1
- data/sig/router.rbs +8 -2
- data/sig/vdom.rbs +2 -0
- metadata +5 -4
|
Binary file
|
data/lib/funicular/version.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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"
|
data/lib/tasks/funicular.rake
CHANGED
|
@@ -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
|
|
82
|
+
desc "Install Funicular debug JS/CSS assets"
|
|
83
83
|
task :debug_assets do
|
|
84
84
|
require "fileutils"
|
|
85
85
|
|
|
86
|
-
javascripts_dir
|
|
87
|
-
stylesheets_dir
|
|
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
|
|
95
|
-
source_css
|
|
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
|
|
99
|
-
dest_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,
|
|
103
|
-
FileUtils.cp(source_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=""hi""></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
|
|
713
|
-
children = dom_element
|
|
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
|
|
752
|
-
children = dom_element
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
107
|
-
# 3. inserts in
|
|
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
|
|
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
|
-
#
|
|
19
|
-
#
|
|
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/html_serializer.rb
CHANGED
|
@@ -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
|
-
|
|
60
|
+
lower = tag.downcase
|
|
61
|
+
if VOID_ELEMENTS.include?(lower)
|
|
54
62
|
"<#{tag}#{attrs}>"
|
|
55
63
|
else
|
|
56
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
73
|
-
|
|
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
|
|
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
|
|
74
|
+
# 2. apply content updates to kept children. The
|
|
69
75
|
# lookup is by snapshot[old_index], so updates are stable
|
|
70
|
-
# regardless of
|
|
71
|
-
# 3.
|
|
72
|
-
# insertBefore on the live DOM.
|
|
73
|
-
# new_index order so each
|
|
74
|
-
#
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
if
|
|
115
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
169
|
+
# text Node cases (the latter via :text or :replace).
|
|
144
170
|
apply(child_element, child_patches)
|
|
145
171
|
end
|
|
146
172
|
end
|