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
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
|
-
|
|
193
|
-
|
|
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
|
|
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
|
-
|
|
501
|
-
|
|
589
|
+
case value
|
|
590
|
+
when Hash
|
|
502
591
|
normalized = {} #: Hash[untyped, untyped]
|
|
503
|
-
value.each
|
|
504
|
-
normalized[k] = normalize_state_value(v)
|
|
505
|
-
end
|
|
592
|
+
value.each { |k, v| normalized[k] = normalize_state_value(v) }
|
|
506
593
|
normalized
|
|
507
|
-
|
|
508
|
-
# Recursively normalize array elements
|
|
594
|
+
when Array
|
|
509
595
|
value.map { |v| normalize_state_value(v) }
|
|
510
|
-
|
|
511
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
end
|
|
129
|
+
ops << [:keep, old_index, new_index, child_patches]
|
|
130
|
+
has_change = true unless child_patches.empty?
|
|
121
131
|
else
|
|
122
|
-
|
|
123
|
-
|
|
132
|
+
ops << [:insert, new_index, new_child]
|
|
133
|
+
has_change = true
|
|
124
134
|
end
|
|
125
135
|
else
|
|
126
|
-
#
|
|
127
|
-
#
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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)
|
data/mrblib/file_upload.rb
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
|
|
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)
|
data/mrblib/form_builder.rb
CHANGED
|
@@ -6,8 +6,15 @@ module Funicular
|
|
|
6
6
|
@component = component
|
|
7
7
|
@model_key = model_key
|
|
8
8
|
@options = options
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: "
|
|
128
|
-
field_error_class: "
|
|
213
|
+
error_class: "funicular-error",
|
|
214
|
+
field_error_class: "funicular-field-error"
|
|
129
215
|
}
|
|
130
|
-
|
|
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
|