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
data/mrblib/component.rb CHANGED
@@ -62,6 +62,25 @@ module Funicular
62
62
  {}
63
63
  end
64
64
 
65
+ # Merge externally provided state into the component before rendering.
66
+ # Used by SSR to inject server data, and by client hydration to restore
67
+ # the same state from window.__FUNICULAR_STATE__ so the first client
68
+ # render matches the server HTML. Top-level keys are symbolized so they
69
+ # are reachable via state.foo (StateAccessor uses symbol keys); nested
70
+ # values are left untouched (components read them with string keys).
71
+ def seed_state(state_hash)
72
+ return self if state_hash.nil?
73
+ return self if state_hash.respond_to?(:empty?) && state_hash.empty?
74
+
75
+ symbolized = {} #: Hash[Symbol, untyped]
76
+ state_hash.each do |key, value|
77
+ symbolized[key.to_sym] = value
78
+ end
79
+ @state = @state.merge(symbolized)
80
+ @state_accessor = nil
81
+ self
82
+ end
83
+
65
84
  # Load all registered suspense data
66
85
  # Called automatically in component_mounted if suspense definitions exist
67
86
  def load_suspense_data
@@ -173,10 +192,14 @@ module Funicular
173
192
  # div { user.name }
174
193
  # end
175
194
  def suspense(fallback:, error: nil, &block)
195
+ current_children = @current_children
196
+ child_count_before = current_children&.size
197
+ result = nil
198
+
176
199
  # Check for any rejected suspense
177
200
  rejected_name = self.class.suspense_definitions.keys.find { |name| @suspense_states[name] == :rejected }
178
201
  if rejected_name
179
- if error
202
+ result = if error
180
203
  error.call(@suspense_errors[rejected_name])
181
204
  else
182
205
  fallback.call
@@ -184,13 +207,17 @@ module Funicular
184
207
  elsif suspense_loading?
185
208
  # Check if any suspense is still loading
186
209
  # Note: Loading is started in mount(), not here
187
- fallback.call
210
+ result = fallback.call
188
211
  else
189
212
  # All suspense data loaded, render content
190
- block.call
213
+ result = block.call
214
+ end
215
+
216
+ if current_children && current_children.size == child_count_before
217
+ add_child(result)
191
218
  end
192
- # Note: We don't add result to @current_children because
193
- # the DSL methods inside fallback/block already do that
219
+
220
+ result
194
221
  end
195
222
 
196
223
  # Class methods for styles DSL
@@ -298,6 +325,65 @@ module Funicular
298
325
  end
299
326
  end
300
327
 
328
+ # Hydrate this component against server-rendered DOM.
329
+ #
330
+ # Unlike mount, which builds a fresh DOM tree via VDOM::Renderer, hydrate
331
+ # reuses the existing DOM produced by the server: it builds the VDOM,
332
+ # associates it with the existing nodes, and only attaches event listeners
333
+ # and refs (plus wiring child components). The first client render must
334
+ # match the server HTML, which is why state is seeded from
335
+ # window.__FUNICULAR_STATE__ before calling this.
336
+ def hydrate(dom_element)
337
+ return if @mounted
338
+ raise "hydrate: missing server DOM element" unless dom_element
339
+
340
+ begin
341
+ component_will_mount if respond_to?(:component_will_mount)
342
+
343
+ # The router/start sets the container; without it, unmount could not
344
+ # detach this subtree later. Derive it from the existing DOM.
345
+ @container ||= dom_element.parentNode
346
+ @dom_element = dom_element
347
+
348
+ new_vdom = build_vdom
349
+
350
+ if hydration_match?(new_vdom, dom_element)
351
+ # Wire child components first so their instances exist for collection.
352
+ hydrate_child_components(new_vdom, dom_element)
353
+
354
+ # Reuse the same positional walks as mount: they skip Component vnodes
355
+ # (children manage their own events/refs during their own hydrate).
356
+ bind_events(dom_element, new_vdom)
357
+ collect_refs(dom_element, new_vdom)
358
+ collect_child_components(new_vdom)
359
+ else
360
+ # Server and client disagree on structure (nondeterministic render or
361
+ # stale state). Recover by discarding the server DOM and rendering a
362
+ # fresh tree, the same way mount does. The page stays usable; only the
363
+ # first-paint reuse is lost for this subtree.
364
+ warn_hydration_mismatch(new_vdom, dom_element)
365
+ @dom_element = full_render_fallback(new_vdom, dom_element)
366
+ end
367
+
368
+ @vdom = new_vdom
369
+ @mounted = true
370
+
371
+ load_suspense_data if self.class.suspense_definitions.any?
372
+
373
+ @child_components.each do |child|
374
+ unless child.mounted
375
+ child.mounted = true
376
+ child.component_mounted if child.respond_to?(:component_mounted)
377
+ end
378
+ end
379
+
380
+ component_mounted if respond_to?(:component_mounted)
381
+ rescue => e
382
+ component_raised(e) if respond_to?(:component_raised)
383
+ raise e
384
+ end
385
+ end
386
+
301
387
  # Unmount component from DOM
302
388
  def unmount
303
389
  return unless @mounted
@@ -495,42 +581,25 @@ module Funicular
495
581
 
496
582
  private
497
583
 
498
- # Normalize state value by converting JS::Object to Ruby native types
584
+ # Normalize state value for storage. Primitives are already auto-
585
+ # converted to Ruby native values by picoruby-wasm, so only composite
586
+ # JS::Object values (array / plain object / function / symbol / bigint)
587
+ # and nested Ruby collections need handling here.
499
588
  def normalize_state_value(value)
500
- if value.is_a?(Hash)
501
- # Recursively normalize hash values
589
+ case value
590
+ when Hash
502
591
  normalized = {} #: Hash[untyped, untyped]
503
- value.each do |k, v|
504
- normalized[k] = normalize_state_value(v)
505
- end
592
+ value.each { |k, v| normalized[k] = normalize_state_value(v) }
506
593
  normalized
507
- elsif value.is_a?(Array)
508
- # Recursively normalize array elements
594
+ when Array
509
595
  value.map { |v| normalize_state_value(v) }
510
- elsif value.is_a?(JS::Object)
511
- # Convert JS::Object to appropriate Ruby type
512
- case value.type
513
- when :string
514
- value.to_s
515
- when :number
516
- # Check if it's an integer or float
517
- num = value.to_f
518
- num == num.to_i ? num.to_i : num
519
- when :boolean
520
- # JS::Object#== now supports direct comparison with Ruby true/false
521
- value == true
522
- when :null, :undefined
523
- nil
524
- when :array
596
+ when JS::Object
597
+ if value.typeof == :array
525
598
  value.to_a.map { |v| normalize_state_value(v) }
526
- when :object
527
- # For plain objects, keep as JS::Object or convert to hash if needed
528
- value
529
599
  else
530
600
  value
531
601
  end
532
602
  else
533
- # Return as-is for Ruby native types
534
603
  value
535
604
  end
536
605
  end
@@ -546,7 +615,10 @@ module Funicular
546
615
  cleanup_events
547
616
 
548
617
  unless patches.empty?
549
- @dom_element = VDOM::Patcher.new.apply(@dom_element, patches)
618
+ new_dom_element = VDOM::Patcher.new.apply(@dom_element, patches)
619
+ # apply returns JS::Object (it must accept text-node patches), but
620
+ # the component's root is always an Element. Narrow to JS::Element.
621
+ @dom_element = new_dom_element if new_dom_element.is_a?(JS::Element)
550
622
  end
551
623
 
552
624
  bind_events(@dom_element, new_vdom)
@@ -599,6 +671,64 @@ module Funicular
599
671
  end
600
672
  end
601
673
 
674
+ # Lightweight structural check: the root tag of the freshly built VDOM
675
+ # must match the server-rendered element. A mismatch means server and
676
+ # client disagree (nondeterministic render or stale state); the caller
677
+ # falls back to a full client render.
678
+ def hydration_match?(vnode, dom_element)
679
+ return true unless vnode.is_a?(VDOM::Element)
680
+ actual = dom_element[:tagName]
681
+ return true unless actual # non-element node; let later steps surface issues
682
+ vnode.tag.to_s.downcase == actual.to_s.downcase
683
+ end
684
+
685
+ # Emit a development-only warning describing a hydration mismatch. Uses
686
+ # puts so the message reaches the browser console (same idiom as the
687
+ # ErrorBoundary logger), and is silent in production.
688
+ def warn_hydration_mismatch(vnode, dom_element)
689
+ return unless Funicular.env.development?
690
+ expected = vnode.is_a?(VDOM::Element) ? vnode.tag.to_s.downcase : vnode.class.to_s
691
+ got = dom_element[:tagName].to_s.downcase
692
+ puts "[Funicular] Hydration mismatch: expected <#{expected}>, found <#{got}>; " \
693
+ "falling back to full client render"
694
+ end
695
+
696
+ # Recover from a hydration mismatch by rendering a fresh DOM tree and
697
+ # swapping it in for the server-rendered node. Mirrors mount, minus the
698
+ # appendChild: the stale node already has a place in the document, so we
699
+ # replaceChild instead.
700
+ def full_render_fallback(new_vdom, server_dom)
701
+ fresh = VDOM::Renderer.new.render(new_vdom)
702
+ parent = server_dom.parentNode
703
+ parent.replaceChild(fresh, server_dom) if parent
704
+ bind_events(fresh, new_vdom)
705
+ collect_refs(fresh, new_vdom)
706
+ collect_child_components(new_vdom)
707
+ fresh
708
+ end
709
+
710
+ # Walk the VDOM and existing DOM in parallel to hydrate nested components.
711
+ # Uses the same positional indexing as bind_events/collect_refs so the
712
+ # three walks agree on which DOM node maps to which vnode.
713
+ def hydrate_child_components(vnode, dom_element)
714
+ return unless vnode.is_a?(VDOM::Element)
715
+ return unless dom_element
716
+
717
+ dom_children = dom_element.children.to_a
718
+ vnode.children.each_with_index do |child, index|
719
+ child_dom = dom_children[index]
720
+ next unless child_dom
721
+
722
+ if child.is_a?(VDOM::Component)
723
+ instance = child.component_class.new(child.props)
724
+ child.instance = instance
725
+ instance.hydrate(child_dom)
726
+ elsif child.is_a?(VDOM::Element)
727
+ hydrate_child_components(child, child_dom)
728
+ end
729
+ end
730
+ end
731
+
602
732
  # Collect child component instances from VDOM tree
603
733
  def collect_child_components(vnode)
604
734
  @child_components = []
@@ -632,6 +762,13 @@ module Funicular
632
762
  br hr
633
763
  ]
634
764
 
765
+ # The HTML tag DSL methods are public: besides being called inside render
766
+ # (implicit self), they are invoked by collaborators such as FormBuilder
767
+ # with an explicit receiver (@component.div). A private method would forbid
768
+ # that under CRuby (it is tolerated under mruby), which would break SSR of
769
+ # any component using form_for. Keep them public on both VMs.
770
+ public
771
+
635
772
  HTML_TAGS.each do |tag|
636
773
  define_method(tag) do |props = {}, &block|
637
774
  # @type self: Component
@@ -674,6 +811,8 @@ module Funicular
674
811
  end
675
812
  end
676
813
 
814
+ private
815
+
677
816
  # Helper to add children in DSL blocks
678
817
  def add_child(child)
679
818
  return unless @rendering && @current_children
data/mrblib/debug.rb CHANGED
@@ -4,6 +4,9 @@ module Funicular
4
4
  attr_accessor :enabled
5
5
 
6
6
  def enabled?
7
+ # Disabled on the server: SSR instantiates components per request and
8
+ # must not accumulate them in the debug registry.
9
+ return false if Funicular.server?
7
10
  @enabled ||= Funicular.env.development?
8
11
  end
9
12
 
data/mrblib/differ.rb CHANGED
@@ -90,9 +90,22 @@ module Funicular
90
90
  end
91
91
  end
92
92
 
93
+ # Produce a single composite patch describing a keyed-children diff.
94
+ #
95
+ # The earlier implementation emitted independent `[index, ...]` patches
96
+ # for inserts, removes and updates. The patcher consumed them as
97
+ # `replaceChild`, which is unsafe whenever insert and remove indices
98
+ # overlap (e.g. switching between two equal-length sets of keyed
99
+ # children with disjoint keys): inserts overwrite old nodes in place,
100
+ # then the descending removes drop the just-inserted new nodes and
101
+ # the DOM ends up empty.
102
+ #
103
+ # The new shape is `[[:keyed_children, ops, removes]]`, applied as
104
+ # three phases by the patcher:
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
93
108
  def self.diff_children_with_keys(old_children, new_children)
94
- patches = [] #: Array[patch_t]
95
-
96
109
  # 1. Build key map from old children
97
110
  old_key_map = {} #: Hash[untyped, [Integer, child_t]]
98
111
  old_children.each_with_index do |child, index|
@@ -101,63 +114,60 @@ module Funicular
101
114
  end
102
115
  end
103
116
 
104
- # 2. Track matched old indices
105
117
  matched_old_indices = {} #: Hash[Integer, bool]
118
+ ops = [] #: Array[Array[untyped]]
119
+ has_change = false
106
120
 
107
- # 3. Process new children
121
+ # 2. Process new children, recording one op per new position
108
122
  new_children.each_with_index do |new_child, new_index|
109
123
  if new_child.is_a?(VNode) && new_child.respond_to?(:key) && new_child.key
110
124
  key = new_child.key
111
-
112
125
  if old_key_map[key]
113
126
  old_index, old_child = old_key_map[key]
114
127
  matched_old_indices[old_index] = true
115
-
116
- # Diff existing element
117
128
  child_patches = diff(old_child, new_child)
118
- unless child_patches.empty?
119
- patches << [new_index, child_patches]
120
- end
129
+ ops << [:keep, old_index, new_index, child_patches]
130
+ has_change = true unless child_patches.empty?
121
131
  else
122
- # Insert new element
123
- patches << [new_index, [[:replace, new_child, nil]]] # old_node is nil for insertion
132
+ ops << [:insert, new_index, new_child]
133
+ has_change = true
124
134
  end
125
135
  else
126
- # Fallback to index-based for elements without keys
127
- # Even if old_child.nil?, diff will handle it (insertion)
136
+ # Unkeyed new child: positional fallback. Match it with the
137
+ # old child at the same index only when that old child is
138
+ # itself unkeyed (otherwise the keyed old child is matched
139
+ # separately via its key, and the unkeyed new is a fresh insert).
128
140
  old_child = old_children[new_index]
129
- child_patches = diff(old_child, new_child)
130
- unless child_patches.empty?
131
- patches << [new_index, child_patches]
141
+ old_is_keyed = old_child.is_a?(VNode) && old_child.respond_to?(:key) && old_child.key
142
+ if old_child.nil? || old_is_keyed
143
+ ops << [:insert, new_index, new_child]
144
+ has_change = true
145
+ else
146
+ matched_old_indices[new_index] = true
147
+ child_patches = diff(old_child, new_child)
148
+ ops << [:keep, new_index, new_index, child_patches]
149
+ has_change = true unless child_patches.empty?
132
150
  end
133
151
  end
134
152
  end
135
153
 
136
- # 4. Remove unmatched old elements
154
+ # 3. Collect every unmatched old child to remove, keyed or not. An old
155
+ # child that no new child matched (by key, or positionally for
156
+ # unkeyed ones) is gone from the new render and must be removed.
157
+ # Skipping unkeyed olds here left stale nodes in the DOM, e.g. a
158
+ # "Loading..." placeholder that never disappeared once the keyed
159
+ # list it was replaced by arrived.
160
+ removes = [] #: Array[[Integer, child_t]]
137
161
  old_children.each_with_index do |old_child, old_index|
138
162
  next unless old_child.is_a?(VNode)
139
163
  next if matched_old_indices[old_index]
140
-
141
- if old_child.respond_to?(:key) && old_child.key # Only remove keyed children
142
- patches << [old_index, [[:remove, old_child]]]
143
- end
164
+ removes << [old_index, old_child]
165
+ has_change = true
144
166
  end
145
167
 
146
- # Sort patches: updates first, removes last (descending index)
147
- patches.sort { |a, b|
148
- a_is_remove = a[1].length == 1 && a[1][0][0] == :remove
149
- b_is_remove = b[1].length == 1 && b[1][0][0] == :remove
150
-
151
- if a_is_remove && b_is_remove
152
- b[0] <=> a[0] # Sort remove patches by descending index
153
- elsif a_is_remove
154
- 1 # Remove patches come after non-remove patches
155
- elsif b_is_remove
156
- -1 # Non-remove patches come before remove patches
157
- else
158
- a[0] <=> b[0] # Sort non-remove patches by ascending index
159
- end
160
- }
168
+ return [] unless has_change
169
+
170
+ [[:keyed_children, ops, removes]]
161
171
  end
162
172
 
163
173
  def self.diff_children_by_index(old_children, new_children)
@@ -1,4 +1,11 @@
1
- require 'rng'
1
+ # 'rng' is a PicoRuby gem available only in the wasm build. When the runtime
2
+ # is loaded under CRuby for SSR it is absent; FileUpload is client-only and
3
+ # its methods are never called on the server (see Funicular.server?).
4
+ begin
5
+ require 'rng'
6
+ rescue LoadError
7
+ # not available outside wasm environment
8
+ end
2
9
 
3
10
  module Funicular
4
11
  module FileUpload
@@ -31,6 +38,7 @@ module Funicular
31
38
  JAVASCRIPT
32
39
 
33
40
  def self.mount
41
+ return if Funicular.server?
34
42
  script = JS.document.createElement("script")
35
43
  script[:textContent] = JS_HELPER_CODE
36
44
  JS.document.body.appendChild(script)
@@ -6,8 +6,15 @@ module Funicular
6
6
  @component = component
7
7
  @model_key = model_key
8
8
  @options = options
9
- @error_class = options[:error_class] || "text-red-600 text-sm mt-1"
10
- @field_error_class = options[:field_error_class] || "border-red-500"
9
+ # Per-form options win, then the global Funicular.configure_forms config,
10
+ # then the built-in defaults. The defaults are semantic class names whose
11
+ # CSS the gem ships and injects via picoruby_include_tag, so error styling
12
+ # works without depending on the host app's CSS pipeline (e.g. Tailwind,
13
+ # which never scans the gem and so would not generate utility classes
14
+ # emitted from here).
15
+ config = Funicular.form_builder_config || {}
16
+ @error_class = options[:error_class] || config[:error_class] || "funicular-error"
17
+ @field_error_class = options[:field_error_class] || config[:field_error_class] || "funicular-field-error"
11
18
  end
12
19
 
13
20
  # Generic field builder for input elements
@@ -26,7 +33,10 @@ module Funicular
26
33
 
27
34
  # Check for errors
28
35
  error_message = @component.state.errors ? @component.state.errors[field_key.to_sym] : nil
29
- has_error = !error_message.nil?
36
+ # errors may be a single message (legacy) or an array of messages
37
+ # (Funicular::Model::Errors#messages). Show the first.
38
+ error_message = error_message.first if error_message.is_a?(Array)
39
+ has_error = !(error_message.nil? || error_message == "")
30
40
 
31
41
  # Merge CSS classes (add error class if error exists)
32
42
  css_class = field_options[:class]
@@ -89,7 +99,10 @@ module Funicular
89
99
  end
90
100
 
91
101
  error_message = @component.state.errors ? @component.state.errors[field_key.to_sym] : nil
92
- has_error = !error_message.nil?
102
+ # errors may be a single message (legacy) or an array of messages
103
+ # (Funicular::Model::Errors#messages). Show the first.
104
+ error_message = error_message.first if error_message.is_a?(Array)
105
+ has_error = !(error_message.nil? || error_message == "")
93
106
 
94
107
  css_class = options[:class]
95
108
  css_class = css_class.to_s if css_class
@@ -149,7 +162,10 @@ module Funicular
149
162
  end
150
163
 
151
164
  error_message = @component.state.errors ? @component.state.errors[field_key.to_sym] : nil
152
- has_error = !error_message.nil?
165
+ # errors may be a single message (legacy) or an array of messages
166
+ # (Funicular::Model::Errors#messages). Show the first.
167
+ error_message = error_message.first if error_message.is_a?(Array)
168
+ has_error = !(error_message.nil? || error_message == "")
153
169
 
154
170
  css_class = options[:class]
155
171
  css_class = css_class.to_s if css_class
data/mrblib/funicular.rb CHANGED
@@ -15,12 +15,28 @@ rescue LoadError
15
15
  end
16
16
 
17
17
  module Funicular
18
- VERSION = '0.1.0'
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.1.0' unless Funicular.const_defined?(:VERSION)
19
22
 
20
23
  def self.version
21
24
  VERSION
22
25
  end
23
26
 
27
+ # True when the runtime is loaded under CRuby on the server (SSR) rather
28
+ # than running as PicoRuby.wasm in the browser. JS-dependent entry points
29
+ # become no-ops in this mode. Defaults to false (browser).
30
+ @server = false
31
+
32
+ def self.server?
33
+ @server
34
+ end
35
+
36
+ def self.server=(value)
37
+ @server = value ? true : false
38
+ end
39
+
24
40
  def self.env
25
41
  @env ||= EnvironmentInquirer.new(ENV['FUNICULAR_ENV'] || ENV['RAILS_ENV'] || 'development')
26
42
  end
@@ -44,12 +60,57 @@ module Funicular
44
60
  @router
45
61
  end
46
62
 
63
+ # Read the SSR state embedded by the server (funicular_state_tag) as a
64
+ # Ruby Hash with string keys. Returns {} when absent or on the server.
65
+ # Goes through JSON.stringify/parse for a reliable JS->Ruby conversion.
66
+ def self.window_state
67
+ return {} if server?
68
+ win = JS.global[:window]
69
+ # @type var win: JS::Object?
70
+ return {} unless win
71
+ raw = win[:__FUNICULAR_STATE__]
72
+ return {} if raw.nil?
73
+ json = JS.global[:JSON]
74
+ # @type var json: untyped
75
+ json_str = json.stringify(raw)
76
+ JSON.parse(json_str.to_s)
77
+ rescue => e
78
+ puts "[Funicular] Failed to read window state: #{e.message}"
79
+ {}
80
+ end
81
+
82
+ # True when the server embedded hydration state on the page.
83
+ def self.has_ssr_state?
84
+ return false if server?
85
+ win = JS.global[:window]
86
+ # @type var win: JS::Object?
87
+ return false unless win
88
+ !win[:__FUNICULAR_STATE__].nil?
89
+ rescue
90
+ false
91
+ end
92
+
93
+ # The first element child of a container, or nil. Used to find the
94
+ # server-rendered root for hydration.
95
+ def self.first_element_child(container_element)
96
+ child = container_element[:firstElementChild]
97
+ child.is_a?(JS::Element) ? child : nil
98
+ end
99
+
47
100
  # Load schemas for models
48
101
  # Usage:
49
102
  # Funicular.load_schemas({ User => "user", Session => "session" }) do
50
103
  # Funicular.start(container: 'app') { |router| ... }
51
104
  # end
52
105
  def self.load_schemas(models, &block)
106
+ # On the server there is no fetch and no need for client-side schemas:
107
+ # SSR injects plain data into component state directly. Just run the
108
+ # block so route registration (Funicular.start) still happens.
109
+ if server?
110
+ block.call if block
111
+ return
112
+ end
113
+
53
114
  schemas_loaded = 0
54
115
  total_schemas = models.size
55
116
 
@@ -78,7 +139,20 @@ module Funicular
78
139
  # Usage:
79
140
  # Funicular.start(MyComponent, container: 'app')
80
141
  # Funicular.start(MyComponent, container: 'app', props: { name: 'John' })
81
- def self.start(component_class = nil, container: 'app', props: {}, &block)
142
+ def self.start(component_class = nil, container: 'app', props: {}, hydrate: false, &block)
143
+ # On the server we only need route registration so SSR can resolve a
144
+ # path to a component. Skip all DOM/JS work (container lookup, popstate
145
+ # listener, debug export).
146
+ if server?
147
+ if block
148
+ router = Router.new(nil)
149
+ @router = router
150
+ block.call(router)
151
+ return router
152
+ end
153
+ return nil
154
+ end
155
+
82
156
  # Export debug configuration to JavaScript
83
157
  export_debug_config
84
158
 
@@ -91,23 +165,33 @@ module Funicular
91
165
  container
92
166
  end
93
167
 
94
- unless container_element
168
+ unless container_element.is_a?(JS::Element)
95
169
  raise "Container element not found: #{container}"
96
170
  end
97
171
 
172
+ # Hydrate automatically when the server embedded state, unless the caller
173
+ # explicitly opted out.
174
+ hydrate = true if hydrate == false && has_ssr_state?
175
+
98
176
  # If block is given, use router mode
99
177
  if block
100
178
  router = Router.new(container_element)
101
179
  @router = router
102
180
  block.call(router)
103
- router.start
181
+ router.start(hydrate: hydrate)
104
182
  return router
105
183
  end
106
184
 
107
185
  # Otherwise, mount single component (backward compatible)
108
186
  if component_class
109
187
  instance = component_class.new(props)
110
- instance.mount(container_element)
188
+ server_root = hydrate ? first_element_child(container_element) : nil
189
+ if server_root
190
+ instance.seed_state(window_state)
191
+ instance.hydrate(server_root)
192
+ else
193
+ instance.mount(container_element)
194
+ end
111
195
  return instance
112
196
  end
113
197
 
@@ -123,11 +207,15 @@ module Funicular
123
207
  attr_accessor :form_builder_config
124
208
 
125
209
  def configure_forms
210
+ # Defaults are semantic class names whose CSS the gem ships and injects
211
+ # via picoruby_include_tag (see assets/funicular.css).
126
212
  @form_builder_config ||= {
127
- error_class: "text-red-600 text-sm mt-1",
128
- field_error_class: "border-red-500"
213
+ error_class: "funicular-error",
214
+ field_error_class: "funicular-field-error"
129
215
  }
130
- yield @form_builder_config if block_given?
216
+ config = @form_builder_config
217
+ # @type var config: Hash[Symbol, String]
218
+ yield config if block_given?
131
219
  end
132
220
  end
133
221
 
@@ -149,6 +237,7 @@ module Funicular
149
237
 
150
238
  # Export debug_color to JavaScript global variable
151
239
  def self.export_debug_config
240
+ return if server?
152
241
  if JS.global[:window]
153
242
  JS.global[:window][:FUNICULAR_DEBUG_COLOR] = @debug_color # steep:ignore
154
243
  end