funicular 0.1.0 → 0.2.0
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 +24 -0
- data/README.md +10 -2
- data/Rakefile +29 -0
- data/docs/architecture.md +113 -404
- data/lib/funicular/assets/funicular.css +23 -0
- data/lib/funicular/compiler.rb +23 -15
- data/lib/funicular/helpers/picoruby_helper.rb +65 -3
- data/lib/funicular/middleware.rb +34 -9
- data/lib/funicular/plugin.rb +147 -0
- data/lib/funicular/schema.rb +167 -0
- data/lib/funicular/ssr/runtime.rb +101 -0
- data/lib/funicular/ssr.rb +51 -0
- data/lib/funicular/testing/node_runner.mjs +293 -0
- data/lib/funicular/testing/node_runner.rb +190 -0
- data/lib/funicular/testing.rb +22 -0
- data/lib/funicular/vendor/picorbc/picorbc.wasm +0 -0
- data/lib/funicular/vendor/picoruby/debug/picoruby.js +94 -75
- 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 -0
- data/lib/funicular/vendor/picoruby-test-node/picoruby.js +6885 -0
- data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm.map +1 -0
- data/lib/funicular/version.rb +1 -1
- data/lib/funicular.rb +3 -0
- data/lib/generators/funicular/chat/chat_generator.rb +104 -0
- data/lib/generators/funicular/chat/templates/application_cable_channel.rb.tt +4 -0
- data/lib/generators/funicular/chat/templates/application_cable_connection.rb.tt +4 -0
- data/lib/generators/funicular/chat/templates/create_funicular_chat_messages.rb.tt +10 -0
- data/lib/generators/funicular/chat/templates/funicular_chat.css.tt +141 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_channel.rb.tt +5 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_component.rb.tt +135 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_component_picotest.rb.tt +64 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_controller.rb.tt +4 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_message.rb.tt +13 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_messages_controller.rb.tt +23 -0
- data/lib/generators/funicular/chat/templates/initializer.rb.tt +4 -0
- data/lib/generators/funicular/chat/templates/show.html.erb.tt +6 -0
- data/lib/tasks/funicular.rake +87 -4
- data/minitest/fixtures/funicular_app/components/greeting_component.rb +16 -0
- data/minitest/fixtures/funicular_app/initializer.rb +5 -0
- data/minitest/hydration_test.rb +87 -0
- data/minitest/plugin_test.rb +51 -0
- data/minitest/schema_test.rb +106 -0
- data/minitest/ssr_test.rb +94 -0
- data/minitest/validations_test.rb +183 -0
- data/mrbgem.rake +1 -0
- data/mrblib/0_validations.rb +206 -0
- data/mrblib/1_validators.rb +180 -0
- data/mrblib/cable.rb +24 -9
- data/mrblib/component.rb +172 -33
- data/mrblib/debug.rb +3 -0
- data/mrblib/differ.rb +47 -37
- data/mrblib/file_upload.rb +9 -1
- data/mrblib/form_builder.rb +21 -5
- data/mrblib/funicular.rb +97 -8
- data/mrblib/html_serializer.rb +121 -0
- data/mrblib/http.rb +123 -29
- data/mrblib/model.rb +50 -0
- data/mrblib/patcher.rb +74 -8
- data/mrblib/router.rb +40 -3
- data/mrblib/store.rb +304 -0
- data/mrblib/store_collection.rb +171 -0
- data/mrblib/store_singleton.rb +79 -0
- data/sig/cable.rbs +1 -0
- data/sig/component.rbs +13 -5
- data/sig/funicular.rbs +14 -1
- data/sig/html_serializer.rbs +20 -0
- data/sig/http.rbs +21 -6
- data/sig/model.rbs +6 -1
- data/sig/patcher.rbs +4 -1
- data/sig/router.rbs +3 -2
- data/sig/store.rbs +89 -0
- data/sig/store_collection.rbs +43 -0
- data/sig/store_singleton.rbs +19 -0
- data/sig/validations.rbs +103 -0
- data/sig/vdom.rbs +6 -6
- metadata +47 -12
- data/docs/README.md +0 -419
- data/docs/advanced-features.md +0 -632
- data/docs/components-and-state.md +0 -539
- data/docs/data-fetching.md +0 -528
- data/docs/forms.md +0 -446
- data/docs/rails-integration.md +0 -426
- data/docs/realtime.md +0 -543
- data/docs/routing-and-navigation.md +0 -427
- data/docs/styling.md +0 -285
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
module Funicular
|
|
2
|
+
module VDOM
|
|
3
|
+
# HTMLSerializer is a string-emitting counterpart of VDOM::Renderer.
|
|
4
|
+
# While Renderer builds a live DOM tree via JS (createElement/appendChild),
|
|
5
|
+
# HTMLSerializer walks the same VNode tree and produces an HTML string.
|
|
6
|
+
#
|
|
7
|
+
# It is pure Ruby with no JS dependency, so it runs under both mruby
|
|
8
|
+
# (in the browser, if ever needed) and CRuby (on the Rails server for SSR).
|
|
9
|
+
#
|
|
10
|
+
# Event handler props (on*) are intentionally skipped: they are Procs that
|
|
11
|
+
# cannot be serialized. They are re-bound on the client during hydration.
|
|
12
|
+
class HTMLSerializer
|
|
13
|
+
# Elements that have no closing tag and no children.
|
|
14
|
+
VOID_ELEMENTS = %w[
|
|
15
|
+
area base br col embed hr img input link meta param source track wbr
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
# Props that must never be emitted as HTML attributes.
|
|
19
|
+
SKIP_PROPS = %i[ref key children_block]
|
|
20
|
+
|
|
21
|
+
def self.serialize(vnode)
|
|
22
|
+
new.render(vnode)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def render(vnode)
|
|
26
|
+
case vnode&.type
|
|
27
|
+
when :element
|
|
28
|
+
# @type var vnode: Funicular::VDOM::Element
|
|
29
|
+
render_element(vnode)
|
|
30
|
+
when :text
|
|
31
|
+
# @type var vnode: Funicular::VDOM::Text
|
|
32
|
+
render_text(vnode)
|
|
33
|
+
when :component
|
|
34
|
+
# @type var vnode: Funicular::VDOM::Component
|
|
35
|
+
render_component(vnode)
|
|
36
|
+
when nil
|
|
37
|
+
""
|
|
38
|
+
else
|
|
39
|
+
raise "Unknown vnode type: #{vnode&.type}"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def render_element(element)
|
|
46
|
+
tag = element.tag
|
|
47
|
+
attrs = serialize_props(element.props)
|
|
48
|
+
|
|
49
|
+
if VOID_ELEMENTS.include?(tag)
|
|
50
|
+
"<#{tag}#{attrs}>"
|
|
51
|
+
else
|
|
52
|
+
"<#{tag}#{attrs}>#{render_children(element.children)}</#{tag}>"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def render_children(children)
|
|
57
|
+
parts = [] #: Array[String]
|
|
58
|
+
children.each do |child|
|
|
59
|
+
if child.is_a?(VNode)
|
|
60
|
+
parts << render(child)
|
|
61
|
+
elsif child.is_a?(String)
|
|
62
|
+
parts << escape_html(child)
|
|
63
|
+
elsif child.is_a?(Array)
|
|
64
|
+
parts << render_children(child)
|
|
65
|
+
elsif !child.nil?
|
|
66
|
+
parts << escape_html(child.to_s)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
parts.join
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def render_text(text)
|
|
73
|
+
escape_html(text.content)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def render_component(component_vnode)
|
|
77
|
+
instance = component_vnode.component_class.new(component_vnode.props)
|
|
78
|
+
component_vnode.instance = instance
|
|
79
|
+
render(instance.build_vdom)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def serialize_props(props)
|
|
83
|
+
parts = [] #: Array[String]
|
|
84
|
+
props.each do |key, value|
|
|
85
|
+
key_str = key.to_s
|
|
86
|
+
next if SKIP_PROPS.include?(key)
|
|
87
|
+
# Event handlers are bound on the client, never serialized.
|
|
88
|
+
next if key_str.start_with?("on")
|
|
89
|
+
|
|
90
|
+
if URL_ATTRIBUTES.include?(key_str) &&
|
|
91
|
+
value.to_s.strip.downcase.start_with?("javascript:")
|
|
92
|
+
# Prevent XSS via javascript: URIs (mirrors Renderer#render_element).
|
|
93
|
+
next
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
if BOOLEAN_ATTRIBUTES.include?(key_str)
|
|
97
|
+
# Boolean attributes are absent when false/nil, otherwise present.
|
|
98
|
+
next if value.nil? || value.to_s == "false"
|
|
99
|
+
parts << " #{key_str}=\"#{key_str}\""
|
|
100
|
+
else
|
|
101
|
+
parts << " #{key_str}=\"#{escape_attr(value.to_s)}\""
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
parts.join
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Minimal HTML escaping that works the same under mruby and CRuby.
|
|
108
|
+
# Avoids any dependency on CGI/ERB::Util.
|
|
109
|
+
def escape_html(str)
|
|
110
|
+
str.to_s
|
|
111
|
+
.gsub("&", "&")
|
|
112
|
+
.gsub("<", "<")
|
|
113
|
+
.gsub(">", ">")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def escape_attr(str)
|
|
117
|
+
escape_html(str).gsub('"', """)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
data/mrblib/http.rb
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
module Funicular
|
|
2
2
|
module HTTP
|
|
3
|
+
CACHE_DB_NAME = 'funicular_http_cache'.freeze
|
|
4
|
+
CACHE_STORE = 'responses'.freeze
|
|
5
|
+
|
|
6
|
+
@cache = nil
|
|
7
|
+
|
|
3
8
|
class Response
|
|
4
9
|
attr_reader :data, :status, :ok
|
|
5
10
|
|
|
@@ -21,23 +26,73 @@ module Funicular
|
|
|
21
26
|
end
|
|
22
27
|
end
|
|
23
28
|
|
|
24
|
-
|
|
25
|
-
|
|
29
|
+
# Open (or reuse) the response cache store. Idempotent and safe to call
|
|
30
|
+
# multiple times. Falls back to the in-memory backing if browser
|
|
31
|
+
# IndexedDB is unavailable.
|
|
32
|
+
def self.cache_init!
|
|
33
|
+
cache = @cache
|
|
34
|
+
return cache if cache
|
|
35
|
+
@cache = IndexedDB::KVS.open(CACHE_DB_NAME, store: CACHE_STORE)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Drop a single cached entry by URL key. No-op if the cache is not
|
|
39
|
+
# initialized.
|
|
40
|
+
def self.cache_purge(url)
|
|
41
|
+
cache = @cache
|
|
42
|
+
return nil unless cache
|
|
43
|
+
cache.delete(url)
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Drop every cached entry. No-op if the cache is not initialized.
|
|
48
|
+
def self.cache_clear
|
|
49
|
+
cache = @cache
|
|
50
|
+
return nil unless cache
|
|
51
|
+
cache.clear
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Internal: read the cache for *url*. Returns the parsed entry hash or
|
|
56
|
+
# nil. Lazily initializes the cache on first use so callers can pass
|
|
57
|
+
# `cache:` without booting the SPA shell first.
|
|
58
|
+
def self.cache_lookup(url)
|
|
59
|
+
cache_init! unless @cache
|
|
60
|
+
cache = @cache
|
|
61
|
+
return nil unless cache
|
|
62
|
+
cache[url]
|
|
26
63
|
end
|
|
27
64
|
|
|
28
|
-
|
|
65
|
+
# Internal: write *entry* (a Hash with status/data/cached_at) to the
|
|
66
|
+
# cache. Awaits one extra Promise so the next request reliably hits.
|
|
67
|
+
def self.cache_write(url, entry)
|
|
68
|
+
cache_init! unless @cache
|
|
69
|
+
cache = @cache
|
|
70
|
+
return nil unless cache
|
|
71
|
+
cache[url] = entry
|
|
72
|
+
nil
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def self.get(url, cache: nil, &block)
|
|
76
|
+
request("GET", url, nil, cache: cache, &block)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def self.post(url, body = nil, cache: nil, &block)
|
|
80
|
+
warn_unsupported_cache("post") if cache
|
|
29
81
|
request("POST", url, body, &block)
|
|
30
82
|
end
|
|
31
83
|
|
|
32
|
-
def self.patch(url, body = nil, &block)
|
|
84
|
+
def self.patch(url, body = nil, cache: nil, &block)
|
|
85
|
+
warn_unsupported_cache("patch") if cache
|
|
33
86
|
request("PATCH", url, body, &block)
|
|
34
87
|
end
|
|
35
88
|
|
|
36
|
-
def self.delete(url, &block)
|
|
89
|
+
def self.delete(url, cache: nil, &block)
|
|
90
|
+
warn_unsupported_cache("delete") if cache
|
|
37
91
|
request("DELETE", url, nil, &block)
|
|
38
92
|
end
|
|
39
93
|
|
|
40
|
-
def self.put(url, body = nil, &block)
|
|
94
|
+
def self.put(url, body = nil, cache: nil, &block)
|
|
95
|
+
warn_unsupported_cache("put") if cache
|
|
41
96
|
request("PUT", url, body, &block)
|
|
42
97
|
end
|
|
43
98
|
|
|
@@ -46,43 +101,82 @@ module Funicular
|
|
|
46
101
|
def self.csrf_token
|
|
47
102
|
meta = JS.document.querySelector('meta[name="csrf-token"]')
|
|
48
103
|
if meta
|
|
49
|
-
# Use getAttribute method (direct method call on JS::Object)
|
|
50
104
|
token_obj = meta.getAttribute('content')
|
|
51
|
-
# Convert JS::Object to Ruby string
|
|
52
105
|
token_obj ? token_obj.to_s : nil
|
|
53
106
|
else
|
|
54
107
|
nil
|
|
55
108
|
end
|
|
56
109
|
end
|
|
57
110
|
|
|
58
|
-
|
|
111
|
+
class << self
|
|
112
|
+
private
|
|
59
113
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
headers = {} #: Hash[String, String]
|
|
114
|
+
def warn_unsupported_cache(verb)
|
|
115
|
+
puts "[Funicular::HTTP] cache: option is GET-only; ignoring on #{verb.upcase}"
|
|
116
|
+
end
|
|
65
117
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
118
|
+
def now_seconds
|
|
119
|
+
# JavaScript Date.now() returns ms since epoch
|
|
120
|
+
ms = JS.global[:Date].now # steep:ignore
|
|
121
|
+
(ms.to_i / 1000)
|
|
69
122
|
end
|
|
70
123
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
124
|
+
def cache_hit?(entry, ttl)
|
|
125
|
+
return false unless entry.is_a?(Hash)
|
|
126
|
+
cached_at = entry["cached_at"]
|
|
127
|
+
return false unless cached_at.is_a?(Integer)
|
|
128
|
+
(now_seconds - cached_at) <= ttl
|
|
75
129
|
end
|
|
76
130
|
|
|
77
|
-
|
|
131
|
+
def serve_from_cache(entry, &block)
|
|
132
|
+
status = entry["status"].to_i
|
|
133
|
+
data = entry["data"]
|
|
134
|
+
block.call(Response.new(status, data)) if block
|
|
135
|
+
end
|
|
78
136
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
137
|
+
def request(method, url, body, cache: nil, &block)
|
|
138
|
+
if method == "GET" && cache.is_a?(Integer) && cache > 0
|
|
139
|
+
entry = cache_lookup(url)
|
|
140
|
+
if cache_hit?(entry, cache)
|
|
141
|
+
serve_from_cache(entry, &block)
|
|
142
|
+
return
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# @type var options: Hash[Symbol, String | Hash[String, String]]
|
|
147
|
+
options = { method: method, credentials: "include" }
|
|
148
|
+
|
|
149
|
+
headers = {} #: Hash[String, String]
|
|
150
|
+
|
|
151
|
+
if body
|
|
152
|
+
headers["Content-Type"] = "application/json"
|
|
153
|
+
options[:body] = JSON.generate(body)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
if method != "GET"
|
|
157
|
+
token = csrf_token
|
|
158
|
+
headers["X-CSRF-Token"] = token if token
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
options[:headers] = headers unless headers.empty?
|
|
162
|
+
|
|
163
|
+
JS.global.fetch(url, options) do |response|
|
|
164
|
+
status = response.status.to_i
|
|
165
|
+
json_text = response.to_binary
|
|
166
|
+
data = JSON.parse(json_text)
|
|
167
|
+
# @type var status: Integer
|
|
168
|
+
http_response = Response.new(status, data)
|
|
169
|
+
|
|
170
|
+
if method == "GET" && cache.is_a?(Integer) && cache > 0 && http_response.ok
|
|
171
|
+
cache_write(url, {
|
|
172
|
+
"status" => status,
|
|
173
|
+
"data" => data,
|
|
174
|
+
"cached_at" => now_seconds
|
|
175
|
+
})
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
block.call(http_response) if block
|
|
179
|
+
end
|
|
86
180
|
end
|
|
87
181
|
end
|
|
88
182
|
end
|
data/mrblib/model.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
module Funicular
|
|
2
2
|
class Model
|
|
3
|
+
include Validations
|
|
4
|
+
|
|
3
5
|
attr_reader :id
|
|
4
6
|
|
|
5
7
|
class << self
|
|
@@ -22,6 +24,41 @@ module Funicular
|
|
|
22
24
|
@changed_attributes[name] = value
|
|
23
25
|
end
|
|
24
26
|
end
|
|
27
|
+
|
|
28
|
+
# Validations are inlined per attribute by Funicular::Schema.build.
|
|
29
|
+
register_schema_validations(name => config["validations"]) if config["validations"]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Backward-compatible: a top-level { attr => rules } block also works.
|
|
33
|
+
register_schema_validations(schema_data["validations"])
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# validations: { "attr" => { "presence" => true, "length" => { "maximum" => 30 } } }
|
|
37
|
+
def self.register_schema_validations(validations)
|
|
38
|
+
return unless validations.is_a?(Hash)
|
|
39
|
+
validations.each do |attribute, rules|
|
|
40
|
+
next unless rules.is_a?(Hash)
|
|
41
|
+
rules.each do |kind, opts|
|
|
42
|
+
options = normalize_validation_options(kind, opts)
|
|
43
|
+
add_schema_validator(attribute, kind, options)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Turn JSON-shaped validator options into the Ruby options the client
|
|
49
|
+
# validators expect (notably rebuilding a Regexp for `format`). Integer
|
|
50
|
+
# Regexp flags are used so this works the same under CRuby and the client
|
|
51
|
+
# JS RegExp wrapper.
|
|
52
|
+
def self.normalize_validation_options(kind, opts)
|
|
53
|
+
return opts unless opts.is_a?(Hash)
|
|
54
|
+
if kind.to_s == "format" && opts["with"]
|
|
55
|
+
flags = opts["flags"].to_s
|
|
56
|
+
bits = 0
|
|
57
|
+
bits |= Regexp::IGNORECASE if flags.include?("i")
|
|
58
|
+
bits |= Regexp::MULTILINE if flags.include?("m")
|
|
59
|
+
{ with: Regexp.new(opts["with"], bits) }
|
|
60
|
+
else
|
|
61
|
+
opts
|
|
25
62
|
end
|
|
26
63
|
end
|
|
27
64
|
|
|
@@ -70,6 +107,13 @@ module Funicular
|
|
|
70
107
|
endpoint = @endpoints["create"]
|
|
71
108
|
return unless endpoint
|
|
72
109
|
|
|
110
|
+
# Validate on the client before the request (mirrors ActiveRecord#save).
|
|
111
|
+
candidate = new(attrs)
|
|
112
|
+
unless candidate.valid?
|
|
113
|
+
block.call(nil, candidate.errors) if block
|
|
114
|
+
return
|
|
115
|
+
end
|
|
116
|
+
|
|
73
117
|
HTTP.post(endpoint["path"], attrs) do |response|
|
|
74
118
|
if response.error?
|
|
75
119
|
block.call(nil, response.error_message) if block
|
|
@@ -101,6 +145,12 @@ module Funicular
|
|
|
101
145
|
attrs.each { |k, v| send("#{k}=", v) }
|
|
102
146
|
end
|
|
103
147
|
|
|
148
|
+
# Validate on the client before the request (mirrors ActiveRecord#save).
|
|
149
|
+
unless valid?
|
|
150
|
+
block.call(false, errors) if block
|
|
151
|
+
return
|
|
152
|
+
end
|
|
153
|
+
|
|
104
154
|
return if @changed_attributes.empty?
|
|
105
155
|
|
|
106
156
|
json_attrs = @changed_attributes.reject do |name, value|
|
data/mrblib/patcher.rb
CHANGED
|
@@ -20,7 +20,9 @@ module Funicular
|
|
|
20
20
|
result = new_element
|
|
21
21
|
end
|
|
22
22
|
when :props
|
|
23
|
-
|
|
23
|
+
# Props patches only make sense for Element nodes (text nodes have
|
|
24
|
+
# no attributes); narrow before delegating.
|
|
25
|
+
update_props(element, patch[1]) if element.is_a?(JS::Element)
|
|
24
26
|
when :update_and_rebind
|
|
25
27
|
instance, internal_patches, new_vdom = patch[1], patch[2], patch[3]
|
|
26
28
|
old_dom_element = instance.dom_element
|
|
@@ -29,7 +31,7 @@ module Funicular
|
|
|
29
31
|
new_dom_element = Patcher.new(@doc).apply(old_dom_element, internal_patches)
|
|
30
32
|
|
|
31
33
|
# Update the instance's reference to its root DOM element if it changed
|
|
32
|
-
if new_dom_element != old_dom_element
|
|
34
|
+
if new_dom_element != old_dom_element && new_dom_element.is_a?(JS::Element)
|
|
33
35
|
instance.dom_element = new_dom_element
|
|
34
36
|
end
|
|
35
37
|
|
|
@@ -37,11 +39,13 @@ module Funicular
|
|
|
37
39
|
instance.vdom = new_vdom
|
|
38
40
|
|
|
39
41
|
# Re-collect refs using the new DOM element
|
|
40
|
-
|
|
42
|
+
if new_dom_element.is_a?(JS::Element)
|
|
43
|
+
instance.collect_refs(new_dom_element, new_vdom)
|
|
41
44
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
+
# Re-bind events using the new DOM element
|
|
46
|
+
instance.cleanup_events
|
|
47
|
+
instance.bind_events(new_dom_element, new_vdom)
|
|
48
|
+
end
|
|
45
49
|
|
|
46
50
|
# Call component_updated on the child instance
|
|
47
51
|
instance.component_updated if instance.respond_to?(:component_updated)
|
|
@@ -54,6 +58,64 @@ module Funicular
|
|
|
54
58
|
old_vnode = patch[1]
|
|
55
59
|
unmount_component(old_vnode)
|
|
56
60
|
element.parentElement&.removeChild(element)
|
|
61
|
+
when :keyed_children
|
|
62
|
+
# Composite patch produced by Differ.diff_children_with_keys.
|
|
63
|
+
# Applied in three phases against `element`:
|
|
64
|
+
# 1. snapshot DOM children, then remove unmatched keyed old
|
|
65
|
+
# children (descending old_index so the snapshot indices
|
|
66
|
+
# remain valid as removes happen).
|
|
67
|
+
# 2. apply content updates to kept children in place. The
|
|
68
|
+
# lookup is by snapshot[old_index], so updates are stable
|
|
69
|
+
# regardless of subsequent insertions.
|
|
70
|
+
# 3. insert new children at their new_index using
|
|
71
|
+
# insertBefore on the live DOM. Processed in ascending
|
|
72
|
+
# new_index order so each insertion fixes its own
|
|
73
|
+
# position before later inserts run.
|
|
74
|
+
ops = patch[1]
|
|
75
|
+
removes = patch[2]
|
|
76
|
+
|
|
77
|
+
child_nodes = element[:childNodes]
|
|
78
|
+
snapshot = child_nodes.is_a?(JS::Object) ? child_nodes.to_a : [] #: Array[untyped]
|
|
79
|
+
|
|
80
|
+
# Phase 1: removes (descending old_index)
|
|
81
|
+
sorted_removes = removes.sort { |a, b| b[0] <=> a[0] }
|
|
82
|
+
sorted_removes.each do |entry|
|
|
83
|
+
old_index = entry[0]
|
|
84
|
+
old_vnode = entry[1]
|
|
85
|
+
target = snapshot[old_index]
|
|
86
|
+
next if target.nil?
|
|
87
|
+
unmount_component(old_vnode)
|
|
88
|
+
parent_el = target.parentElement
|
|
89
|
+
parent_el.removeChild(target) if parent_el
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Phase 2: updates against the snapshot (no movement)
|
|
93
|
+
ops.each do |op|
|
|
94
|
+
next unless op[0] == :keep
|
|
95
|
+
old_index = op[1]
|
|
96
|
+
child_patches = op[3]
|
|
97
|
+
next if child_patches.empty?
|
|
98
|
+
target = snapshot[old_index]
|
|
99
|
+
next if target.nil?
|
|
100
|
+
apply(target, child_patches)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Phase 3: inserts in ascending new_index order
|
|
104
|
+
ops.each do |op|
|
|
105
|
+
next unless op[0] == :insert
|
|
106
|
+
new_index = op[1]
|
|
107
|
+
new_vnode = op[2]
|
|
108
|
+
new_node = create_element(new_vnode)
|
|
109
|
+
next if new_node.nil?
|
|
110
|
+
live_nodes = element[:childNodes]
|
|
111
|
+
live_arr = live_nodes.is_a?(JS::Object) ? live_nodes.to_a : [] #: Array[untyped]
|
|
112
|
+
ref = live_arr[new_index]
|
|
113
|
+
if ref.nil?
|
|
114
|
+
element.appendChild(new_node) if element.is_a?(JS::Element)
|
|
115
|
+
else
|
|
116
|
+
element.insertBefore(new_node, ref) if element.is_a?(JS::Element)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
57
119
|
when Integer
|
|
58
120
|
child_index = patch[0]
|
|
59
121
|
child_patches = patch[1]
|
|
@@ -67,7 +129,7 @@ module Funicular
|
|
|
67
129
|
case child_patch[0]
|
|
68
130
|
when :replace
|
|
69
131
|
new_child_element = create_element(child_patch[1])
|
|
70
|
-
element.appendChild(new_child_element)
|
|
132
|
+
element.appendChild(new_child_element) if element.is_a?(JS::Element)
|
|
71
133
|
when :remove
|
|
72
134
|
# Nothing to remove if child doesn't exist
|
|
73
135
|
when Integer
|
|
@@ -75,7 +137,9 @@ module Funicular
|
|
|
75
137
|
# But if it does, recursively process it
|
|
76
138
|
end
|
|
77
139
|
end
|
|
78
|
-
|
|
140
|
+
elsif child_element.is_a?(JS::Object)
|
|
141
|
+
# Recurse into the child node. apply handles both Element and
|
|
142
|
+
# text Node cases (the latter only meaningfully via :replace).
|
|
79
143
|
apply(child_element, child_patches)
|
|
80
144
|
end
|
|
81
145
|
end
|
|
@@ -91,6 +155,8 @@ module Funicular
|
|
|
91
155
|
end
|
|
92
156
|
|
|
93
157
|
def update_props(element, props_patch)
|
|
158
|
+
return unless element.is_a?(JS::Element)
|
|
159
|
+
|
|
94
160
|
props_patch.each do |key, value|
|
|
95
161
|
key_str = key.to_s
|
|
96
162
|
|
data/mrblib/router.rb
CHANGED
|
@@ -44,8 +44,22 @@ module Funicular
|
|
|
44
44
|
@default_route = path
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
+
# Resolve a path to [component_class, params] without any DOM/JS work.
|
|
48
|
+
# Public entry point used by server-side rendering.
|
|
49
|
+
def match(path)
|
|
50
|
+
find_route(path)
|
|
51
|
+
end
|
|
52
|
+
|
|
47
53
|
# Start listening to popstate
|
|
48
|
-
|
|
54
|
+
#
|
|
55
|
+
# When hydrate is true and the container already holds server-rendered
|
|
56
|
+
# markup, the initial route hydrates that DOM instead of mounting fresh.
|
|
57
|
+
def start(hydrate: false)
|
|
58
|
+
# No browser history on the server.
|
|
59
|
+
return if Funicular.server?
|
|
60
|
+
|
|
61
|
+
@hydrate_initial = hydrate
|
|
62
|
+
|
|
49
63
|
# Clean up existing listener if any (prevents duplicate registration)
|
|
50
64
|
if @popstate_callback_id
|
|
51
65
|
JS::Object.removeEventListener(@popstate_callback_id)
|
|
@@ -57,8 +71,10 @@ module Funicular
|
|
|
57
71
|
handle_route_change
|
|
58
72
|
end
|
|
59
73
|
|
|
60
|
-
# Handle initial route
|
|
61
|
-
|
|
74
|
+
# Handle initial route. Skip the default-route redirect when hydrating
|
|
75
|
+
# server content: the server already rendered for the current path.
|
|
76
|
+
hydrating_now = @hydrate_initial && Funicular.first_element_child(@container)
|
|
77
|
+
if !hydrating_now && current_location_path == '/' && @default_route
|
|
62
78
|
# Use replaceState to not add a new entry to the history
|
|
63
79
|
JS.global.history.replaceState(JS::Bridge.to_js({}), '', @default_route)
|
|
64
80
|
end
|
|
@@ -95,6 +111,11 @@ module Funicular
|
|
|
95
111
|
def handle_route_change
|
|
96
112
|
path = current_location_path
|
|
97
113
|
|
|
114
|
+
# Hydration only applies to the very first navigation. Consume the flag
|
|
115
|
+
# here so an unmatched initial route does not leave it set for later.
|
|
116
|
+
hydrate_now = @hydrate_initial
|
|
117
|
+
@hydrate_initial = false
|
|
118
|
+
|
|
98
119
|
# Find matching route
|
|
99
120
|
component_class, params = find_route(path)
|
|
100
121
|
|
|
@@ -113,6 +134,22 @@ module Funicular
|
|
|
113
134
|
@current_path = path
|
|
114
135
|
@current_component = component_class.new(params)
|
|
115
136
|
# @type ivar @current_component: Funicular::Component
|
|
137
|
+
|
|
138
|
+
server_root = hydrate_now ? Funicular.first_element_child(@container) : nil
|
|
139
|
+
|
|
140
|
+
if server_root
|
|
141
|
+
begin
|
|
142
|
+
@current_component.seed_state(Funicular.window_state)
|
|
143
|
+
@current_component.hydrate(server_root)
|
|
144
|
+
return
|
|
145
|
+
rescue => e
|
|
146
|
+
# Server/client disagreed: discard server DOM and render fresh.
|
|
147
|
+
puts "[Funicular] Hydration failed, falling back to full render: #{e.message}"
|
|
148
|
+
@container[:innerHTML] = ''
|
|
149
|
+
@current_component = component_class.new(params)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
116
153
|
@current_component.mount(@container)
|
|
117
154
|
end
|
|
118
155
|
|