funicular 0.0.1 → 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 +79 -0
- data/README.md +66 -20
- data/Rakefile +103 -2
- data/demo/keymap_editor.html +582 -0
- data/demo/test_cable.html +179 -0
- data/demo/test_chartjs.html +235 -0
- data/demo/test_component.html +201 -0
- data/demo/test_diff_patch.html +146 -0
- data/demo/test_error_boundary.html +284 -0
- data/demo/test_router.html +257 -0
- data/demo/test_vdom.html +100 -0
- data/demo/tic-tac-toe.html +201 -0
- data/docs/architecture.md +118 -0
- data/exe/funicular +32 -0
- data/lib/funicular/assets/funicular.css +23 -0
- data/lib/funicular/assets/funicular.rb +21 -0
- data/lib/funicular/assets/funicular_debug.css +73 -0
- data/lib/funicular/assets/funicular_debug.js +183 -0
- data/lib/funicular/commands/routes.rb +69 -0
- data/lib/funicular/compiler.rb +143 -0
- data/lib/funicular/configuration.rb +76 -0
- data/lib/funicular/helpers/picoruby_helper.rb +112 -0
- data/lib/funicular/middleware.rb +123 -0
- data/lib/funicular/plugin.rb +147 -0
- data/lib/funicular/railtie.rb +26 -0
- data/lib/funicular/route_parser.rb +137 -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/VERSION +1 -0
- data/lib/funicular/vendor/picorbc/picorbc.js +5283 -0
- data/lib/funicular/vendor/picorbc/picorbc.wasm +0 -0
- data/lib/funicular/vendor/picoruby/VERSION +1 -0
- data/lib/funicular/vendor/picoruby/debug/init.iife.js +130 -0
- data/lib/funicular/vendor/picoruby/debug/picoruby.js +6423 -0
- data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby/dist/init.iife.js +130 -0
- data/lib/funicular/vendor/picoruby/dist/picoruby.js +2 -0
- 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 +32 -1
- 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 +218 -0
- data/minitest/fixtures/funicular_app/components/greeting_component.rb +16 -0
- data/minitest/fixtures/funicular_app/initializer.rb +5 -0
- data/minitest/funicular_test.rb +13 -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/test_helper.rb +7 -0
- data/minitest/validations_test.rb +183 -0
- data/mrbgem.rake +16 -0
- data/mrblib/0_validations.rb +206 -0
- data/mrblib/1_validators.rb +180 -0
- data/mrblib/cable.rb +432 -0
- data/mrblib/component.rb +1050 -0
- data/mrblib/debug.rb +208 -0
- data/mrblib/differ.rb +254 -0
- data/mrblib/environment_inquirer.rb +34 -0
- data/mrblib/error_boundary.rb +125 -0
- data/mrblib/file_upload.rb +192 -0
- data/mrblib/form_builder.rb +300 -0
- data/mrblib/funicular.rb +245 -0
- data/mrblib/html_serializer.rb +121 -0
- data/mrblib/http.rb +183 -0
- data/mrblib/model.rb +196 -0
- data/mrblib/patcher.rb +269 -0
- data/mrblib/router.rb +266 -0
- data/mrblib/store.rb +304 -0
- data/mrblib/store_collection.rb +171 -0
- data/mrblib/store_singleton.rb +79 -0
- data/mrblib/styles.rb +83 -0
- data/mrblib/vdom.rb +273 -0
- data/sig/cable.rbs +66 -0
- data/sig/component.rbs +149 -0
- data/sig/debug.rbs +28 -0
- data/sig/differ.rbs +18 -0
- data/sig/environment_iquirer.rbs +10 -0
- data/sig/error_boundary.rbs +14 -0
- data/sig/file_upload.rbs +18 -0
- data/sig/form_builder.rbs +29 -0
- data/sig/funicular.rbs +24 -1
- data/sig/html_serializer.rbs +20 -0
- data/sig/http.rbs +37 -0
- data/sig/model.rbs +28 -0
- data/sig/patcher.rbs +18 -0
- data/sig/router.rbs +44 -0
- data/sig/store.rbs +89 -0
- data/sig/store_collection.rbs +43 -0
- data/sig/store_singleton.rbs +19 -0
- data/sig/styles.rbs +25 -0
- data/sig/validations.rbs +103 -0
- data/sig/vdom.rbs +59 -0
- metadata +154 -8
data/mrblib/component.rb
ADDED
|
@@ -0,0 +1,1050 @@
|
|
|
1
|
+
module Funicular
|
|
2
|
+
class Component
|
|
3
|
+
class StateAccessor
|
|
4
|
+
def initialize(state_hash)
|
|
5
|
+
@state = state_hash
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
# Support dynamic key access: state[:key] or state[variable_key]
|
|
9
|
+
def [](key)
|
|
10
|
+
@state[key]
|
|
11
|
+
end
|
|
12
|
+
def method_missing(method, *args)
|
|
13
|
+
if method.to_s.end_with?('=')
|
|
14
|
+
key = method.to_s[0..-2] || method
|
|
15
|
+
raise "Use patch(#{key}: value) to update state"
|
|
16
|
+
else
|
|
17
|
+
@state[method]
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def respond_to_missing?(method, include_private = false)
|
|
22
|
+
return false if method.to_s.end_with?('=')
|
|
23
|
+
@state.key?(method) || super
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
attr_accessor :props, :vdom, :dom_element, :mounted
|
|
28
|
+
attr_reader :refs
|
|
29
|
+
|
|
30
|
+
def initialize(props = {})
|
|
31
|
+
@props = props
|
|
32
|
+
@state = initialize_state || {}
|
|
33
|
+
@state_accessor = nil
|
|
34
|
+
@style_accessor = nil
|
|
35
|
+
@vdom = nil
|
|
36
|
+
@dom_element = nil
|
|
37
|
+
@refs = {}
|
|
38
|
+
@event_listeners = []
|
|
39
|
+
@mounted = false
|
|
40
|
+
@updating = false
|
|
41
|
+
@child_components = []
|
|
42
|
+
|
|
43
|
+
# Initialize suspense state
|
|
44
|
+
@suspense_data = {}
|
|
45
|
+
@suspense_states = {} # :pending, :loading, :resolved, :rejected
|
|
46
|
+
@suspense_errors = {}
|
|
47
|
+
@suspense_pending_timers = [] # Track pending setTimeout IDs for cleanup
|
|
48
|
+
self.class.suspense_definitions.each_key do |name|
|
|
49
|
+
@suspense_states[name] = :pending
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Register component for debugging in development mode
|
|
53
|
+
@__debug_id__ = Funicular::Debug.register_component(self) if Funicular::Debug.enabled?
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def state
|
|
57
|
+
@state_accessor ||= StateAccessor.new(@state)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Override this method in subclasses to define initial state
|
|
61
|
+
def initialize_state
|
|
62
|
+
{}
|
|
63
|
+
end
|
|
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
|
+
|
|
84
|
+
# Load all registered suspense data
|
|
85
|
+
# Called automatically in component_mounted if suspense definitions exist
|
|
86
|
+
def load_suspense_data
|
|
87
|
+
self.class.suspense_definitions.each do |name, loader|
|
|
88
|
+
load_single_suspense(name, loader)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Load a single suspense data by name
|
|
93
|
+
def load_single_suspense(name, definition = nil)
|
|
94
|
+
definition ||= self.class.suspense_definitions[name]
|
|
95
|
+
return unless definition
|
|
96
|
+
return if @suspense_states[name] == :loading
|
|
97
|
+
|
|
98
|
+
# Support both old format (just loader) and new format (hash with loader and on_resolve)
|
|
99
|
+
if definition.is_a?(Hash)
|
|
100
|
+
loader = definition[:loader]
|
|
101
|
+
on_resolve = definition[:on_resolve]
|
|
102
|
+
min_delay = definition[:min_delay]
|
|
103
|
+
else
|
|
104
|
+
loader = definition
|
|
105
|
+
on_resolve = nil
|
|
106
|
+
min_delay = nil
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
@suspense_states[name] = :loading
|
|
110
|
+
start_time = Time.now.to_f * 1000 # Convert to milliseconds
|
|
111
|
+
|
|
112
|
+
# Helper to finalize resolve
|
|
113
|
+
do_resolve = ->(data) {
|
|
114
|
+
@suspense_data[name] = data
|
|
115
|
+
@suspense_states[name] = :resolved
|
|
116
|
+
@suspense_errors[name] = nil
|
|
117
|
+
if on_resolve
|
|
118
|
+
# on_resolve callback is expected to call patch() which triggers re-render
|
|
119
|
+
instance_exec(data, &on_resolve)
|
|
120
|
+
else
|
|
121
|
+
re_render if @mounted
|
|
122
|
+
end
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
resolve = ->(data) {
|
|
126
|
+
if min_delay
|
|
127
|
+
elapsed = (Time.now.to_f * 1000) - start_time
|
|
128
|
+
remaining = min_delay - elapsed
|
|
129
|
+
if remaining > 0
|
|
130
|
+
# Delay resolve to ensure minimum loading time
|
|
131
|
+
timer_id = JS.global.setTimeout(remaining.to_i) do
|
|
132
|
+
do_resolve.call(data) if @mounted
|
|
133
|
+
end
|
|
134
|
+
@suspense_pending_timers << timer_id
|
|
135
|
+
else
|
|
136
|
+
do_resolve.call(data)
|
|
137
|
+
end
|
|
138
|
+
else
|
|
139
|
+
do_resolve.call(data)
|
|
140
|
+
end
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
reject = ->(error) {
|
|
144
|
+
@suspense_data[name] = nil
|
|
145
|
+
@suspense_states[name] = :rejected
|
|
146
|
+
@suspense_errors[name] = error
|
|
147
|
+
re_render if @mounted
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
# Execute loader with resolve/reject callbacks
|
|
151
|
+
instance_exec(resolve, reject, &loader)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Reload suspense data (useful for retry or refresh)
|
|
155
|
+
def reload_suspense(name)
|
|
156
|
+
@suspense_states[name] = :pending
|
|
157
|
+
load_single_suspense(name)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Check if suspense data is loading
|
|
161
|
+
def suspense_loading?(*names)
|
|
162
|
+
names = self.class.suspense_definitions.keys if names.empty?
|
|
163
|
+
names.any? { |name| @suspense_states[name] == :pending || @suspense_states[name] == :loading }
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Check if suspense data has error
|
|
167
|
+
def suspense_error?(name)
|
|
168
|
+
@suspense_states[name] == :rejected
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Get suspense error
|
|
172
|
+
def suspense_error(name)
|
|
173
|
+
@suspense_errors[name]
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Suspense helper for render method
|
|
177
|
+
#
|
|
178
|
+
# @param fallback [Proc] Content to show while loading
|
|
179
|
+
# @param error [Proc] Optional content to show on error (receives error as argument)
|
|
180
|
+
# @yield Block to render when data is loaded
|
|
181
|
+
#
|
|
182
|
+
# @example
|
|
183
|
+
# suspense(fallback: -> { div { "Loading..." } }) do
|
|
184
|
+
# div { user.name }
|
|
185
|
+
# end
|
|
186
|
+
#
|
|
187
|
+
# @example with error handling
|
|
188
|
+
# suspense(
|
|
189
|
+
# fallback: -> { div { "Loading..." } },
|
|
190
|
+
# error: ->(e) { div { "Error: #{e}" } }
|
|
191
|
+
# ) do
|
|
192
|
+
# div { user.name }
|
|
193
|
+
# end
|
|
194
|
+
def suspense(fallback:, error: nil, &block)
|
|
195
|
+
current_children = @current_children
|
|
196
|
+
child_count_before = current_children&.size
|
|
197
|
+
result = nil
|
|
198
|
+
|
|
199
|
+
# Check for any rejected suspense
|
|
200
|
+
rejected_name = self.class.suspense_definitions.keys.find { |name| @suspense_states[name] == :rejected }
|
|
201
|
+
if rejected_name
|
|
202
|
+
result = if error
|
|
203
|
+
error.call(@suspense_errors[rejected_name])
|
|
204
|
+
else
|
|
205
|
+
fallback.call
|
|
206
|
+
end
|
|
207
|
+
elsif suspense_loading?
|
|
208
|
+
# Check if any suspense is still loading
|
|
209
|
+
# Note: Loading is started in mount(), not here
|
|
210
|
+
result = fallback.call
|
|
211
|
+
else
|
|
212
|
+
# All suspense data loaded, render content
|
|
213
|
+
result = block.call
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
if current_children && current_children.size == child_count_before
|
|
217
|
+
add_child(result)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
result
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Class methods for styles DSL
|
|
224
|
+
def self.styles(&block)
|
|
225
|
+
builder = StyleBuilder.new
|
|
226
|
+
builder.instance_eval(&block)
|
|
227
|
+
@styles_definitions = builder.to_definitions
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def self.styles_definitions
|
|
231
|
+
@styles_definitions ||= {}
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Suspense DSL - register async data loaders
|
|
235
|
+
#
|
|
236
|
+
# @param name [Symbol] Name of the suspense data (becomes accessible as method)
|
|
237
|
+
# @param loader [Proc] Lambda that receives resolve and reject callbacks
|
|
238
|
+
# @param on_resolve [Proc] Optional callback called with data after resolve, before re-render
|
|
239
|
+
# @param min_delay [Integer] Minimum time in ms to show loading state (prevents flickering)
|
|
240
|
+
#
|
|
241
|
+
# @example
|
|
242
|
+
# use_suspense :user, ->(resolve, reject) {
|
|
243
|
+
# User.find(props[:id]) do |user, error|
|
|
244
|
+
# error ? reject.call(error) : resolve.call(user)
|
|
245
|
+
# end
|
|
246
|
+
# }
|
|
247
|
+
#
|
|
248
|
+
# @example with on_resolve callback and min_delay
|
|
249
|
+
# use_suspense :current_user,
|
|
250
|
+
# ->(resolve, reject) { Session.current_user { |u, e| ... } },
|
|
251
|
+
# on_resolve: ->(user) { patch(user: { username: user.username }) },
|
|
252
|
+
# min_delay: 300 # Show loading spinner for at least 300ms
|
|
253
|
+
def self.use_suspense(name, loader, on_resolve: nil, min_delay: nil)
|
|
254
|
+
@suspense_definitions ||= {} # steep:ignore UnannotatedEmptyCollection
|
|
255
|
+
@suspense_definitions[name] = { loader: loader, on_resolve: on_resolve, min_delay: min_delay }
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def self.suspense_definitions
|
|
259
|
+
@suspense_definitions ||= {} # steep:ignore UnannotatedEmptyCollection
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Instance method to access styles
|
|
263
|
+
def s
|
|
264
|
+
@style_accessor ||= StyleAccessor.new(self.class.styles_definitions)
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Update state and trigger re-render
|
|
268
|
+
def patch(new_state)
|
|
269
|
+
return unless @mounted
|
|
270
|
+
return if @updating
|
|
271
|
+
|
|
272
|
+
begin
|
|
273
|
+
@updating = true
|
|
274
|
+
component_will_update if respond_to?(:component_will_update)
|
|
275
|
+
|
|
276
|
+
# Convert JS::Object values to Ruby native types automatically
|
|
277
|
+
normalized_state = {} #: Hash[Symbol, untyped]
|
|
278
|
+
new_state.each do |key, value|
|
|
279
|
+
normalized_state[key] = normalize_state_value(value)
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
@state = @state.merge(normalized_state)
|
|
283
|
+
@state_accessor = nil # Invalidate accessor to reflect new state
|
|
284
|
+
re_render
|
|
285
|
+
|
|
286
|
+
component_updated if respond_to?(:component_updated)
|
|
287
|
+
rescue => e
|
|
288
|
+
component_raised(e) if respond_to?(:component_raised)
|
|
289
|
+
raise e
|
|
290
|
+
ensure
|
|
291
|
+
@updating = false
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Mount component to a DOM container
|
|
296
|
+
def mount(container)
|
|
297
|
+
return if @mounted
|
|
298
|
+
|
|
299
|
+
begin
|
|
300
|
+
component_will_mount if respond_to?(:component_will_mount)
|
|
301
|
+
|
|
302
|
+
@container = container
|
|
303
|
+
new_vdom = build_vdom
|
|
304
|
+
@dom_element = VDOM::Renderer.new.render(new_vdom)
|
|
305
|
+
bind_events(@dom_element, new_vdom)
|
|
306
|
+
collect_refs(@dom_element, new_vdom)
|
|
307
|
+
collect_child_components(new_vdom)
|
|
308
|
+
container.appendChild(@dom_element)
|
|
309
|
+
@vdom = new_vdom
|
|
310
|
+
@mounted = true
|
|
311
|
+
|
|
312
|
+
# Start loading suspense data after mounted
|
|
313
|
+
load_suspense_data if self.class.suspense_definitions.any?
|
|
314
|
+
|
|
315
|
+
# Mark child components as mounted and call their lifecycle hooks
|
|
316
|
+
@child_components.each do |child|
|
|
317
|
+
child.mounted = true
|
|
318
|
+
child.component_mounted if child.respond_to?(:component_mounted)
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
component_mounted if respond_to?(:component_mounted)
|
|
322
|
+
rescue => e
|
|
323
|
+
component_raised(e) if respond_to?(:component_raised)
|
|
324
|
+
raise e
|
|
325
|
+
end
|
|
326
|
+
end
|
|
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
|
+
|
|
387
|
+
# Unmount component from DOM
|
|
388
|
+
def unmount
|
|
389
|
+
return unless @mounted
|
|
390
|
+
# puts "==> Unmounting: #{self.class} id: #{@__debug_id__}"
|
|
391
|
+
|
|
392
|
+
begin
|
|
393
|
+
component_will_unmount if respond_to?(:component_will_unmount)
|
|
394
|
+
|
|
395
|
+
# Unmount child components first
|
|
396
|
+
# puts " > Unmounting children: #{@child_components.map(&:class).join(', ')}"
|
|
397
|
+
@child_components.each do |child|
|
|
398
|
+
child.unmount if child.respond_to?(:unmount)
|
|
399
|
+
end
|
|
400
|
+
@child_components = []
|
|
401
|
+
|
|
402
|
+
cleanup_events
|
|
403
|
+
cleanup_suspense_timers
|
|
404
|
+
@container.removeChild(@dom_element) if @container && @dom_element
|
|
405
|
+
@mounted = false
|
|
406
|
+
@dom_element = nil
|
|
407
|
+
@vdom = nil
|
|
408
|
+
@refs = {}
|
|
409
|
+
|
|
410
|
+
# Unregister component from debugging in development mode
|
|
411
|
+
Funicular::Debug.unregister_component(@__debug_id__) if @__debug_id__
|
|
412
|
+
|
|
413
|
+
component_unmounted if respond_to?(:component_unmounted)
|
|
414
|
+
rescue => e
|
|
415
|
+
component_raised(e) if respond_to?(:component_raised)
|
|
416
|
+
raise e
|
|
417
|
+
end
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
# Override this method in subclasses to define render logic
|
|
421
|
+
def render
|
|
422
|
+
raise "Subclasses must implement the render method"
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
# Build VDOM tree from render method
|
|
426
|
+
# Called by VDOM::Renderer, Differ, and Patcher
|
|
427
|
+
def build_vdom
|
|
428
|
+
@rendering = true
|
|
429
|
+
@current_children = nil
|
|
430
|
+
result = render
|
|
431
|
+
@rendering = false
|
|
432
|
+
|
|
433
|
+
# Convert render result to VNode
|
|
434
|
+
vnode = normalize_vnode(result)
|
|
435
|
+
|
|
436
|
+
# Add data-component attribute to the root element
|
|
437
|
+
if Funicular.env.development? && vnode
|
|
438
|
+
add_data_component_attribute(vnode)
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
vnode
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
# Bind event handlers to DOM elements
|
|
445
|
+
# Called by VDOM::Renderer and Patcher
|
|
446
|
+
def bind_events(dom_element, vnode)
|
|
447
|
+
# Skip Component vnodes - they manage their own events
|
|
448
|
+
return if vnode.is_a?(VDOM::Component)
|
|
449
|
+
return unless vnode.is_a?(VDOM::Element)
|
|
450
|
+
|
|
451
|
+
event_types = [] #: Array[String]
|
|
452
|
+
|
|
453
|
+
vnode.props.each do |key, value|
|
|
454
|
+
key_str = key.to_s
|
|
455
|
+
next unless key_str.start_with?('on')
|
|
456
|
+
|
|
457
|
+
event_name = key_str[2..-1]&.downcase || ""
|
|
458
|
+
event_types << event_name
|
|
459
|
+
|
|
460
|
+
# addEventListener expects a block, not a Proc
|
|
461
|
+
callback_id = case value
|
|
462
|
+
when Symbol
|
|
463
|
+
result = dom_element.addEventListener(event_name) do |event|
|
|
464
|
+
begin
|
|
465
|
+
self.send(value, event)
|
|
466
|
+
rescue => e
|
|
467
|
+
component_raised(e) if respond_to?(:component_raised)
|
|
468
|
+
raise e
|
|
469
|
+
end
|
|
470
|
+
end
|
|
471
|
+
result
|
|
472
|
+
when Method
|
|
473
|
+
result = dom_element.addEventListener(event_name) do |event|
|
|
474
|
+
begin
|
|
475
|
+
# @type var value: Method
|
|
476
|
+
# Check if Method expects arguments (arity)
|
|
477
|
+
if value.arity == 0
|
|
478
|
+
value.call
|
|
479
|
+
else
|
|
480
|
+
value.call(event)
|
|
481
|
+
end
|
|
482
|
+
rescue => e
|
|
483
|
+
component_raised(e) if respond_to?(:component_raised)
|
|
484
|
+
raise e
|
|
485
|
+
end
|
|
486
|
+
end
|
|
487
|
+
result
|
|
488
|
+
when Proc
|
|
489
|
+
result = dom_element.addEventListener(event_name) do |event|
|
|
490
|
+
begin
|
|
491
|
+
# @type var value: Proc
|
|
492
|
+
# Check if Proc expects arguments (arity)
|
|
493
|
+
if value.arity == 0
|
|
494
|
+
value.call
|
|
495
|
+
else
|
|
496
|
+
value.call(event)
|
|
497
|
+
end
|
|
498
|
+
rescue => e
|
|
499
|
+
component_raised(e) if respond_to?(:component_raised)
|
|
500
|
+
raise e
|
|
501
|
+
end
|
|
502
|
+
end
|
|
503
|
+
result
|
|
504
|
+
else
|
|
505
|
+
raise "Invalid event handler: #{value.class}. Must be Symbol, Method, or Proc."
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
@event_listeners << callback_id
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
# Add debug attribute for DevTools
|
|
512
|
+
if Funicular::Debug.enabled? && event_types.length > 0
|
|
513
|
+
dom_element.setAttribute("data-event-listeners", event_types.join(","))
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
# Recursively bind events for children
|
|
517
|
+
if vnode.children && dom_element.children
|
|
518
|
+
children = dom_element.children.to_a
|
|
519
|
+
vnode.children.each_with_index do |child_vnode, index|
|
|
520
|
+
if child_vnode.is_a?(VDOM::Element)
|
|
521
|
+
child_element = children[index]
|
|
522
|
+
bind_events(child_element, child_vnode) if child_element
|
|
523
|
+
elsif child_vnode.is_a?(VDOM::Component)
|
|
524
|
+
# Component vnodes handle their own events in render_component
|
|
525
|
+
# Skip them here to avoid duplicate event binding
|
|
526
|
+
end
|
|
527
|
+
end
|
|
528
|
+
end
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
# Collect ref elements from VDOM
|
|
532
|
+
# Called by VDOM::Renderer and Patcher
|
|
533
|
+
def collect_refs(dom_element, vnode, refs_map = {})
|
|
534
|
+
# Skip Component vnodes - they manage their own refs
|
|
535
|
+
return refs_map if vnode.is_a?(VDOM::Component)
|
|
536
|
+
return refs_map unless vnode.is_a?(VDOM::Element)
|
|
537
|
+
|
|
538
|
+
if vnode.props[:ref]
|
|
539
|
+
ref_name = vnode.props[:ref].to_sym
|
|
540
|
+
@refs[ref_name] = dom_element
|
|
541
|
+
refs_map[ref_name] = dom_element
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
if vnode.children && dom_element.children
|
|
545
|
+
children = dom_element.children.to_a
|
|
546
|
+
vnode.children.each_with_index do |child_vnode, index|
|
|
547
|
+
if child_vnode.is_a?(VDOM::Element)
|
|
548
|
+
child_element = children[index]
|
|
549
|
+
collect_refs(child_element, child_vnode, refs_map) if child_element
|
|
550
|
+
elsif child_vnode.is_a?(VDOM::Component)
|
|
551
|
+
# Component vnodes handle their own refs in render_component
|
|
552
|
+
# Skip them here to avoid duplicate processing
|
|
553
|
+
end
|
|
554
|
+
end
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
refs_map
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
# Cleanup event listeners
|
|
561
|
+
# Called by VDOM::Patcher
|
|
562
|
+
def cleanup_events
|
|
563
|
+
@event_listeners.each do |callback_id|
|
|
564
|
+
JS::Object.removeEventListener(callback_id)
|
|
565
|
+
end
|
|
566
|
+
@event_listeners = []
|
|
567
|
+
|
|
568
|
+
# NOTE: Do NOT cleanup child component events here!
|
|
569
|
+
# Child components manage their own events and will cleanup
|
|
570
|
+
# when they themselves re-render or unmount
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
# Cleanup pending suspense timers
|
|
574
|
+
def cleanup_suspense_timers
|
|
575
|
+
return unless @suspense_pending_timers
|
|
576
|
+
@suspense_pending_timers.each do |timer_id|
|
|
577
|
+
JS.global.clearTimeout(timer_id)
|
|
578
|
+
end
|
|
579
|
+
@suspense_pending_timers = []
|
|
580
|
+
end
|
|
581
|
+
|
|
582
|
+
private
|
|
583
|
+
|
|
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.
|
|
588
|
+
def normalize_state_value(value)
|
|
589
|
+
case value
|
|
590
|
+
when Hash
|
|
591
|
+
normalized = {} #: Hash[untyped, untyped]
|
|
592
|
+
value.each { |k, v| normalized[k] = normalize_state_value(v) }
|
|
593
|
+
normalized
|
|
594
|
+
when Array
|
|
595
|
+
value.map { |v| normalize_state_value(v) }
|
|
596
|
+
when JS::Object
|
|
597
|
+
if value.typeof == :array
|
|
598
|
+
value.to_a.map { |v| normalize_state_value(v) }
|
|
599
|
+
else
|
|
600
|
+
value
|
|
601
|
+
end
|
|
602
|
+
else
|
|
603
|
+
value
|
|
604
|
+
end
|
|
605
|
+
end
|
|
606
|
+
|
|
607
|
+
# Re-render component (called by update)
|
|
608
|
+
def re_render
|
|
609
|
+
return unless @mounted
|
|
610
|
+
|
|
611
|
+
new_vdom = build_vdom
|
|
612
|
+
patches = VDOM::Differ.diff(@vdom, new_vdom)
|
|
613
|
+
|
|
614
|
+
# Always cleanup and rebind events to avoid stale event listeners
|
|
615
|
+
cleanup_events
|
|
616
|
+
|
|
617
|
+
unless patches.empty?
|
|
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)
|
|
622
|
+
end
|
|
623
|
+
|
|
624
|
+
bind_events(@dom_element, new_vdom)
|
|
625
|
+
collect_refs(@dom_element, new_vdom)
|
|
626
|
+
collect_child_components(new_vdom)
|
|
627
|
+
|
|
628
|
+
# Mark child components as mounted and call their lifecycle hooks
|
|
629
|
+
@child_components.each do |child|
|
|
630
|
+
unless child.mounted
|
|
631
|
+
child.mounted = true
|
|
632
|
+
child.component_mounted if child.respond_to?(:component_mounted)
|
|
633
|
+
end
|
|
634
|
+
end
|
|
635
|
+
|
|
636
|
+
@vdom = new_vdom
|
|
637
|
+
end
|
|
638
|
+
|
|
639
|
+
# Add data-component attribute to the root element
|
|
640
|
+
def add_data_component_attribute(vnode)
|
|
641
|
+
return unless vnode.is_a?(VDOM::Element)
|
|
642
|
+
vnode.props[:'data-component'] = self.class.to_s
|
|
643
|
+
vnode.props[:'data-component-id'] = @__debug_id__.to_s if @__debug_id__
|
|
644
|
+
end
|
|
645
|
+
|
|
646
|
+
# Normalize render result to VNode
|
|
647
|
+
def normalize_vnode(value)
|
|
648
|
+
case value
|
|
649
|
+
when VDOM::VNode
|
|
650
|
+
_ = value
|
|
651
|
+
when String
|
|
652
|
+
VDOM::Text.new(value)
|
|
653
|
+
when Integer, Float
|
|
654
|
+
VDOM::Text.new(value.to_s)
|
|
655
|
+
when Array
|
|
656
|
+
# Arrays are typically return values from iterators like .each or .map
|
|
657
|
+
# The elements have already been added to @current_children during iteration
|
|
658
|
+
# Return nil to avoid duplicate rendering
|
|
659
|
+
nil
|
|
660
|
+
when nil
|
|
661
|
+
VDOM::Text.new("")
|
|
662
|
+
when Class
|
|
663
|
+
# If it's a component class, create a component VNode
|
|
664
|
+
if value.ancestors.include?(Funicular::Component)
|
|
665
|
+
VDOM::Component.new(value, {})
|
|
666
|
+
else
|
|
667
|
+
VDOM::Text.new(value.to_s)
|
|
668
|
+
end
|
|
669
|
+
else
|
|
670
|
+
VDOM::Text.new(value.to_s)
|
|
671
|
+
end
|
|
672
|
+
end
|
|
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
|
+
|
|
732
|
+
# Collect child component instances from VDOM tree
|
|
733
|
+
def collect_child_components(vnode)
|
|
734
|
+
@child_components = []
|
|
735
|
+
collect_child_components_recursive(vnode, @child_components)
|
|
736
|
+
end
|
|
737
|
+
|
|
738
|
+
def collect_child_components_recursive(vnode, components)
|
|
739
|
+
if vnode.is_a?(VDOM::Component)
|
|
740
|
+
components << vnode.instance if vnode.instance
|
|
741
|
+
# Recursively collect from child component's vdom
|
|
742
|
+
if vnode.instance && vnode.instance.vdom
|
|
743
|
+
collect_child_components_recursive(vnode.instance.vdom, components)
|
|
744
|
+
end
|
|
745
|
+
elsif vnode.is_a?(VDOM::Element)
|
|
746
|
+
vnode.children&.each do |child|
|
|
747
|
+
# @type var child: VDOM::VNode
|
|
748
|
+
collect_child_components_recursive(child, components)
|
|
749
|
+
end
|
|
750
|
+
end
|
|
751
|
+
end
|
|
752
|
+
|
|
753
|
+
# DSL methods for HTML elements
|
|
754
|
+
HTML_TAGS = %w[
|
|
755
|
+
div span p a
|
|
756
|
+
h1 h2 h3 h4 h5 h6
|
|
757
|
+
ul ol li
|
|
758
|
+
table thead tbody tr th td
|
|
759
|
+
form input textarea button select option label
|
|
760
|
+
header footer nav section article aside
|
|
761
|
+
img video audio canvas
|
|
762
|
+
br hr
|
|
763
|
+
]
|
|
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
|
+
|
|
772
|
+
HTML_TAGS.each do |tag|
|
|
773
|
+
define_method(tag) do |props = {}, &block|
|
|
774
|
+
# @type self: Component
|
|
775
|
+
children = [] #: Array[Funicular::VDOM::child_t]
|
|
776
|
+
|
|
777
|
+
if block
|
|
778
|
+
prev_children = @current_children
|
|
779
|
+
@current_children = children
|
|
780
|
+
# @type var block: Proc
|
|
781
|
+
result = block.call
|
|
782
|
+
@current_children = prev_children
|
|
783
|
+
|
|
784
|
+
# Add block result to children if not already added via add_child
|
|
785
|
+
if result && !result.equal?(children) && children.empty?
|
|
786
|
+
normalized = normalize_vnode(result)
|
|
787
|
+
children << normalized if normalized
|
|
788
|
+
end
|
|
789
|
+
end
|
|
790
|
+
|
|
791
|
+
# Normalize props (convert StyleValue to String for :class)
|
|
792
|
+
normalized_props = {}
|
|
793
|
+
# @type var props: Hash[Symbol, String]
|
|
794
|
+
props.each do |key, value|
|
|
795
|
+
if key == :class && value.is_a?(StyleValue)
|
|
796
|
+
normalized_props[key] = value.to_s
|
|
797
|
+
else
|
|
798
|
+
normalized_props[key] = value
|
|
799
|
+
end
|
|
800
|
+
end
|
|
801
|
+
|
|
802
|
+
# @type var normalized_props: Hash[Symbol, String]
|
|
803
|
+
element = VDOM::Element.new(tag, normalized_props, children)
|
|
804
|
+
|
|
805
|
+
# If we're inside another element's block, add this element to parent's children
|
|
806
|
+
if @rendering && @current_children
|
|
807
|
+
@current_children << element
|
|
808
|
+
end
|
|
809
|
+
|
|
810
|
+
element
|
|
811
|
+
end
|
|
812
|
+
end
|
|
813
|
+
|
|
814
|
+
private
|
|
815
|
+
|
|
816
|
+
# Helper to add children in DSL blocks
|
|
817
|
+
def add_child(child)
|
|
818
|
+
return unless @rendering && @current_children
|
|
819
|
+
|
|
820
|
+
normalized = normalize_vnode(child)
|
|
821
|
+
@current_children << normalized if normalized
|
|
822
|
+
end
|
|
823
|
+
|
|
824
|
+
# Helper method to render child components in DSL
|
|
825
|
+
# Accepts optional block for passing children to the component
|
|
826
|
+
def component(component_class, props = {}, &block)
|
|
827
|
+
unless component_class.is_a?(Class) && component_class.ancestors.include?(Funicular::Component)
|
|
828
|
+
raise "component() expects a Funicular::Component class"
|
|
829
|
+
end
|
|
830
|
+
|
|
831
|
+
# If a block is provided, store the children builder in props
|
|
832
|
+
# This allows components like ErrorBoundary to control child rendering
|
|
833
|
+
if block
|
|
834
|
+
props = props.merge(children_block: block)
|
|
835
|
+
end
|
|
836
|
+
|
|
837
|
+
vnode = VDOM::Component.new(component_class, props)
|
|
838
|
+
|
|
839
|
+
# If we're inside another element's block, add this component to parent's children
|
|
840
|
+
if @rendering && @current_children
|
|
841
|
+
@current_children << vnode
|
|
842
|
+
end
|
|
843
|
+
|
|
844
|
+
vnode
|
|
845
|
+
end
|
|
846
|
+
|
|
847
|
+
# Rails-style form_for helper
|
|
848
|
+
def form_for(model_key, options = {}, &block)
|
|
849
|
+
on_submit = options.delete(:on_submit)
|
|
850
|
+
form_class = options.delete(:class)
|
|
851
|
+
|
|
852
|
+
# Build submit handler
|
|
853
|
+
submit_handler = if on_submit
|
|
854
|
+
->(event) do
|
|
855
|
+
event.preventDefault
|
|
856
|
+
|
|
857
|
+
# Collect form data from state
|
|
858
|
+
model_data = state.send(model_key)
|
|
859
|
+
form_data = if model_data.is_a?(Hash)
|
|
860
|
+
model_data
|
|
861
|
+
elsif model_data.respond_to?(:instance_variables)
|
|
862
|
+
data = {} #: Hash[Symbol, untyped]
|
|
863
|
+
model_data.instance_variables.each do |var|
|
|
864
|
+
key = var.to_s.sub('@', '').to_sym
|
|
865
|
+
data[key] = model_data.instance_variable_get(var)
|
|
866
|
+
end
|
|
867
|
+
data
|
|
868
|
+
else
|
|
869
|
+
{} #: Hash[Symbol, untyped]
|
|
870
|
+
end
|
|
871
|
+
|
|
872
|
+
# Call the submit handler (Symbol, Method, or Proc)
|
|
873
|
+
case on_submit
|
|
874
|
+
when Symbol
|
|
875
|
+
send(on_submit, form_data)
|
|
876
|
+
when Method
|
|
877
|
+
on_submit.call(form_data)
|
|
878
|
+
when Proc
|
|
879
|
+
on_submit.call(form_data)
|
|
880
|
+
else
|
|
881
|
+
raise "on_submit must be Symbol, Method, or Proc, got #{on_submit.class}"
|
|
882
|
+
end
|
|
883
|
+
end
|
|
884
|
+
else
|
|
885
|
+
->(event) { event.preventDefault }
|
|
886
|
+
end
|
|
887
|
+
|
|
888
|
+
# Render form
|
|
889
|
+
form({ onsubmit: submit_handler, class: form_class }.merge(options)) do
|
|
890
|
+
builder = Funicular::FormBuilder.new(self, model_key, options)
|
|
891
|
+
block.call(builder)
|
|
892
|
+
end
|
|
893
|
+
end
|
|
894
|
+
|
|
895
|
+
# Rails-style link_to helper
|
|
896
|
+
# Default behavior: Fetch API for same-page actions (SPA-friendly)
|
|
897
|
+
# Use navigate: true for router navigation (different component tree)
|
|
898
|
+
def link_to(path, method: :get, navigate: false, **options, &block)
|
|
899
|
+
if navigate
|
|
900
|
+
# Navigation: Use <a> tag with real href for browser features
|
|
901
|
+
# (right-click -> open in new tab, link preview, etc.)
|
|
902
|
+
# Note: preventDefault is automatically called by js_add_event_listener
|
|
903
|
+
# for <a> tags to enable SPA navigation
|
|
904
|
+
merged_options = options.merge(href: path)
|
|
905
|
+
merged_options[:onclick] = ->(event) {
|
|
906
|
+
event.preventDefault # Called for clarity, but already handled by JS layer
|
|
907
|
+
handle_link_click(path)
|
|
908
|
+
}
|
|
909
|
+
a(merged_options, &block)
|
|
910
|
+
else
|
|
911
|
+
# Action: Use <div> tag (semantically more appropriate for actions)
|
|
912
|
+
# No href needed, purely onclick-driven
|
|
913
|
+
merged_options = options.merge(
|
|
914
|
+
onclick: -> { handle_link_with_method(path, method) }
|
|
915
|
+
)
|
|
916
|
+
div(merged_options, &block)
|
|
917
|
+
end
|
|
918
|
+
end
|
|
919
|
+
|
|
920
|
+
# Handle router navigation (navigate using History API)
|
|
921
|
+
def handle_link_click(path)
|
|
922
|
+
Funicular.router&.navigate(path)
|
|
923
|
+
end
|
|
924
|
+
|
|
925
|
+
# Handle link action via Fetch API
|
|
926
|
+
def handle_link_with_method(path, method)
|
|
927
|
+
# Call appropriate HTTP method
|
|
928
|
+
case method.to_s.downcase.to_sym
|
|
929
|
+
when :get
|
|
930
|
+
HTTP.get(path) { |response| handle_link_response(response, path, method) }
|
|
931
|
+
when :post
|
|
932
|
+
HTTP.post(path) { |response| handle_link_response(response, path, method) }
|
|
933
|
+
when :put
|
|
934
|
+
HTTP.put(path) { |response| handle_link_response(response, path, method) }
|
|
935
|
+
when :patch
|
|
936
|
+
HTTP.patch(path) { |response| handle_link_response(response, path, method) }
|
|
937
|
+
when :delete
|
|
938
|
+
HTTP.delete(path) { |response| handle_link_response(response, path, method) }
|
|
939
|
+
else
|
|
940
|
+
raise "Unsupported HTTP method: #{method}"
|
|
941
|
+
end
|
|
942
|
+
end
|
|
943
|
+
|
|
944
|
+
# Handle response from link action (can be overridden by subclasses)
|
|
945
|
+
def handle_link_response(response, path, method)
|
|
946
|
+
if response.error?
|
|
947
|
+
puts "Link action failed (#{method.to_s.upcase} #{path}): #{response.error_message}"
|
|
948
|
+
end
|
|
949
|
+
end
|
|
950
|
+
|
|
951
|
+
# Enable URL helpers from RouteHelpers module and suspense data accessors
|
|
952
|
+
def method_missing(method, *args)
|
|
953
|
+
# Check for suspense data accessor
|
|
954
|
+
if self.class.suspense_definitions.key?(method)
|
|
955
|
+
return @suspense_data[method]
|
|
956
|
+
end
|
|
957
|
+
|
|
958
|
+
if Funicular.const_defined?(:RouteHelpers)
|
|
959
|
+
helpers = Funicular::RouteHelpers
|
|
960
|
+
if helpers.instance_methods.include?(method)
|
|
961
|
+
# Include helpers module and retry
|
|
962
|
+
self.class.include(helpers) unless self.class.include?(helpers)
|
|
963
|
+
return send(method, *args)
|
|
964
|
+
end
|
|
965
|
+
end
|
|
966
|
+
super
|
|
967
|
+
end
|
|
968
|
+
|
|
969
|
+
def respond_to_missing?(method, include_private = false)
|
|
970
|
+
# Check for suspense data accessor
|
|
971
|
+
return true if self.class.suspense_definitions.key?(method)
|
|
972
|
+
|
|
973
|
+
if Funicular.const_defined?(:RouteHelpers)
|
|
974
|
+
Funicular::RouteHelpers.instance_methods.include?(method) || super
|
|
975
|
+
else
|
|
976
|
+
super
|
|
977
|
+
end
|
|
978
|
+
end
|
|
979
|
+
|
|
980
|
+
# Transition Helpers
|
|
981
|
+
# These methods provide a declarative way to animate element removal/addition
|
|
982
|
+
# using CSS transitions, inspired by Vue.js and Alpine.js transition systems
|
|
983
|
+
|
|
984
|
+
# Remove an element via CSS transition animation
|
|
985
|
+
#
|
|
986
|
+
# @param element_id [String] DOM element ID (without '#' prefix)
|
|
987
|
+
# @param from [String] CSS classes to remove before animation
|
|
988
|
+
# @param to [String] CSS classes to add for leave animation
|
|
989
|
+
# @param duration [Integer] Animation duration in milliseconds (default: 300)
|
|
990
|
+
# @param callback [Proc] Block called after animation completes
|
|
991
|
+
#
|
|
992
|
+
# @example Remove message with fade out
|
|
993
|
+
# remove_via("message-123",
|
|
994
|
+
# "opacity-100 max-h-screen"
|
|
995
|
+
# "opacity-0 max-h-0",
|
|
996
|
+
# duration: 500,
|
|
997
|
+
# ) do
|
|
998
|
+
# patch(messages: updated_messages)
|
|
999
|
+
# end
|
|
1000
|
+
def remove_via(element_id, from, to, duration: 300, &callback)
|
|
1001
|
+
element = JS.document.getElementById(element_id)
|
|
1002
|
+
|
|
1003
|
+
unless element
|
|
1004
|
+
callback.call if callback
|
|
1005
|
+
return
|
|
1006
|
+
end
|
|
1007
|
+
|
|
1008
|
+
element.classList.remove(*from.split(" ")) unless from.empty?
|
|
1009
|
+
element.classList.add(*to.split(" ")) unless to.empty?
|
|
1010
|
+
|
|
1011
|
+
JS.global.setTimeout(duration) do
|
|
1012
|
+
callback&.call
|
|
1013
|
+
end
|
|
1014
|
+
end
|
|
1015
|
+
|
|
1016
|
+
# Add an element via CSS transition animation
|
|
1017
|
+
#
|
|
1018
|
+
# @param element_id [String] DOM element ID (without '#' prefix)
|
|
1019
|
+
# @param from [String] CSS classes to remove before animation
|
|
1020
|
+
# @param to [String] CSS classes to add for leave animation
|
|
1021
|
+
# @param duration [Integer] Animation duration in milliseconds (default: 300)
|
|
1022
|
+
# @param callback [Proc] Block called after animation completes
|
|
1023
|
+
#
|
|
1024
|
+
# @example Add message with fade in
|
|
1025
|
+
# add_via("message-456",
|
|
1026
|
+
# "opacity-0 scale-95",
|
|
1027
|
+
# "opacity-100 scale-100",
|
|
1028
|
+
# duration: 300
|
|
1029
|
+
# )
|
|
1030
|
+
def add_via(element_id, from, to, duration: 300, &callback)
|
|
1031
|
+
element = JS.document.getElementById(element_id)
|
|
1032
|
+
|
|
1033
|
+
unless element
|
|
1034
|
+
callback.call if callback
|
|
1035
|
+
return
|
|
1036
|
+
end
|
|
1037
|
+
|
|
1038
|
+
element.classList.add(*from.split(" ")) unless from.empty?
|
|
1039
|
+
|
|
1040
|
+
sleep_ms 10
|
|
1041
|
+
|
|
1042
|
+
element.classList.remove(*from.split(" ")) unless from.empty?
|
|
1043
|
+
element.classList.add(*to.split(" ")) unless to.empty?
|
|
1044
|
+
|
|
1045
|
+
JS.global.setTimeout(duration) do
|
|
1046
|
+
callback&.call
|
|
1047
|
+
end
|
|
1048
|
+
end
|
|
1049
|
+
end
|
|
1050
|
+
end
|