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.
Files changed (89) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +24 -0
  3. data/README.md +10 -2
  4. data/Rakefile +29 -0
  5. data/docs/architecture.md +113 -404
  6. data/lib/funicular/assets/funicular.css +23 -0
  7. data/lib/funicular/compiler.rb +23 -15
  8. data/lib/funicular/helpers/picoruby_helper.rb +65 -3
  9. data/lib/funicular/middleware.rb +34 -9
  10. data/lib/funicular/plugin.rb +147 -0
  11. data/lib/funicular/schema.rb +167 -0
  12. data/lib/funicular/ssr/runtime.rb +101 -0
  13. data/lib/funicular/ssr.rb +51 -0
  14. data/lib/funicular/testing/node_runner.mjs +293 -0
  15. data/lib/funicular/testing/node_runner.rb +190 -0
  16. data/lib/funicular/testing.rb +22 -0
  17. data/lib/funicular/vendor/picorbc/picorbc.wasm +0 -0
  18. data/lib/funicular/vendor/picoruby/debug/picoruby.js +94 -75
  19. data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
  20. data/lib/funicular/vendor/picoruby/dist/picoruby.js +1 -1
  21. data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
  22. data/lib/funicular/vendor/picoruby-test-node/VERSION +1 -0
  23. data/lib/funicular/vendor/picoruby-test-node/picoruby.js +6885 -0
  24. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
  25. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm.map +1 -0
  26. data/lib/funicular/version.rb +1 -1
  27. data/lib/funicular.rb +3 -0
  28. data/lib/generators/funicular/chat/chat_generator.rb +104 -0
  29. data/lib/generators/funicular/chat/templates/application_cable_channel.rb.tt +4 -0
  30. data/lib/generators/funicular/chat/templates/application_cable_connection.rb.tt +4 -0
  31. data/lib/generators/funicular/chat/templates/create_funicular_chat_messages.rb.tt +10 -0
  32. data/lib/generators/funicular/chat/templates/funicular_chat.css.tt +141 -0
  33. data/lib/generators/funicular/chat/templates/funicular_chat_channel.rb.tt +5 -0
  34. data/lib/generators/funicular/chat/templates/funicular_chat_component.rb.tt +135 -0
  35. data/lib/generators/funicular/chat/templates/funicular_chat_component_picotest.rb.tt +64 -0
  36. data/lib/generators/funicular/chat/templates/funicular_chat_controller.rb.tt +4 -0
  37. data/lib/generators/funicular/chat/templates/funicular_chat_message.rb.tt +13 -0
  38. data/lib/generators/funicular/chat/templates/funicular_chat_messages_controller.rb.tt +23 -0
  39. data/lib/generators/funicular/chat/templates/initializer.rb.tt +4 -0
  40. data/lib/generators/funicular/chat/templates/show.html.erb.tt +6 -0
  41. data/lib/tasks/funicular.rake +87 -4
  42. data/minitest/fixtures/funicular_app/components/greeting_component.rb +16 -0
  43. data/minitest/fixtures/funicular_app/initializer.rb +5 -0
  44. data/minitest/hydration_test.rb +87 -0
  45. data/minitest/plugin_test.rb +51 -0
  46. data/minitest/schema_test.rb +106 -0
  47. data/minitest/ssr_test.rb +94 -0
  48. data/minitest/validations_test.rb +183 -0
  49. data/mrbgem.rake +1 -0
  50. data/mrblib/0_validations.rb +206 -0
  51. data/mrblib/1_validators.rb +180 -0
  52. data/mrblib/cable.rb +24 -9
  53. data/mrblib/component.rb +172 -33
  54. data/mrblib/debug.rb +3 -0
  55. data/mrblib/differ.rb +47 -37
  56. data/mrblib/file_upload.rb +9 -1
  57. data/mrblib/form_builder.rb +21 -5
  58. data/mrblib/funicular.rb +97 -8
  59. data/mrblib/html_serializer.rb +121 -0
  60. data/mrblib/http.rb +123 -29
  61. data/mrblib/model.rb +50 -0
  62. data/mrblib/patcher.rb +74 -8
  63. data/mrblib/router.rb +40 -3
  64. data/mrblib/store.rb +304 -0
  65. data/mrblib/store_collection.rb +171 -0
  66. data/mrblib/store_singleton.rb +79 -0
  67. data/sig/cable.rbs +1 -0
  68. data/sig/component.rbs +13 -5
  69. data/sig/funicular.rbs +14 -1
  70. data/sig/html_serializer.rbs +20 -0
  71. data/sig/http.rbs +21 -6
  72. data/sig/model.rbs +6 -1
  73. data/sig/patcher.rbs +4 -1
  74. data/sig/router.rbs +3 -2
  75. data/sig/store.rbs +89 -0
  76. data/sig/store_collection.rbs +43 -0
  77. data/sig/store_singleton.rbs +19 -0
  78. data/sig/validations.rbs +103 -0
  79. data/sig/vdom.rbs +6 -6
  80. metadata +47 -12
  81. data/docs/README.md +0 -419
  82. data/docs/advanced-features.md +0 -632
  83. data/docs/components-and-state.md +0 -539
  84. data/docs/data-fetching.md +0 -528
  85. data/docs/forms.md +0 -446
  86. data/docs/rails-integration.md +0 -426
  87. data/docs/realtime.md +0 -543
  88. data/docs/routing-and-navigation.md +0 -427
  89. 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("&", "&amp;")
112
+ .gsub("<", "&lt;")
113
+ .gsub(">", "&gt;")
114
+ end
115
+
116
+ def escape_attr(str)
117
+ escape_html(str).gsub('"', "&quot;")
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
- def self.get(url, &block)
25
- request("GET", url, nil, &block)
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
- def self.post(url, body = nil, &block)
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
- private
111
+ class << self
112
+ private
59
113
 
60
- def self.request(method, url, body, &block)
61
- # @type var options: Hash[Symbol, String | Hash[String, String]]
62
- options = { method: method, credentials: "include" }
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
- if body
67
- headers["Content-Type"] = "application/json"
68
- options[:body] = JSON.generate(body)
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
- # Add CSRF token for non-GET requests
72
- if method != "GET"
73
- token = csrf_token
74
- headers["X-CSRF-Token"] = token if token
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
- options[:headers] = headers unless headers.empty?
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
- JS.global.fetch(url, options) do |response|
80
- status = response.status.to_i
81
- json_text = response.to_binary
82
- data = JSON.parse(json_text)
83
- # @type var status: Integer
84
- http_response = Response.new(status, data)
85
- block.call(http_response) if block
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
- update_props(element, patch[1])
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
- instance.collect_refs(new_dom_element, new_vdom)
42
+ if new_dom_element.is_a?(JS::Element)
43
+ instance.collect_refs(new_dom_element, new_vdom)
41
44
 
42
- # Re-bind events using the new DOM element
43
- instance.cleanup_events
44
- instance.bind_events(new_dom_element, new_vdom)
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
- else
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
- def start
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
- if current_location_path == '/' && @default_route
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