funicular 0.3.0 → 0.5.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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +486 -1
  3. data/demo/local_notes.html +207 -0
  4. data/demo/test_chartjs.html +9 -9
  5. data/demo/test_component.html +8 -8
  6. data/demo/test_error_boundary.html +44 -41
  7. data/demo/test_router.html +48 -48
  8. data/demo/tic-tac-toe.html +25 -25
  9. data/docs/architecture.md +227 -12
  10. data/docs/local_database.md +1035 -0
  11. data/lib/funicular/assets/funicular.rb +14 -0
  12. data/lib/funicular/configuration.rb +65 -0
  13. data/lib/funicular/epoch_header.rb +69 -0
  14. data/lib/funicular/epoch_stamping.rb +66 -0
  15. data/lib/funicular/helpers/picoruby_helper.rb +96 -1
  16. data/lib/funicular/railtie.rb +30 -0
  17. data/lib/funicular/schema.rb +45 -12
  18. data/lib/funicular/session_epoch.rb +110 -0
  19. data/lib/funicular/ssr/runtime.rb +58 -12
  20. data/lib/funicular/ssr.rb +25 -0
  21. data/lib/funicular/testing/node_runner.mjs +19 -0
  22. data/lib/funicular/testing.rb +47 -0
  23. data/lib/funicular/vendor/mrbc/VERSION +1 -1
  24. data/lib/funicular/vendor/mrbc/mrbc.js +655 -574
  25. data/lib/funicular/vendor/mrbc/mrbc.wasm +0 -0
  26. data/lib/funicular/vendor/picoruby/VERSION +1 -1
  27. data/lib/funicular/vendor/picoruby/debug/picoruby.js +800 -530
  28. data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
  29. data/lib/funicular/vendor/picoruby/dist/picoruby.js +2 -2
  30. data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
  31. data/lib/funicular/vendor/picoruby-test-node/VERSION +1 -1
  32. data/lib/funicular/vendor/picoruby-test-node/picoruby.js +2 -6909
  33. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
  34. data/lib/funicular/version.rb +1 -1
  35. data/lib/funicular.rb +1 -0
  36. data/lib/generators/funicular/chat/templates/funicular_chat_component.rb.tt +37 -38
  37. data/lib/tasks/funicular.rake +10 -2
  38. data/minitest/callback_error_visibility_test.rb +48 -0
  39. data/minitest/configuration_test.rb +78 -0
  40. data/minitest/dsl_test.rb +264 -0
  41. data/minitest/epoch_header_test.rb +149 -0
  42. data/minitest/epoch_stamping_test.rb +225 -0
  43. data/minitest/fixtures/funicular_app/components/greeting_component.rb +5 -5
  44. data/minitest/fixtures/funicular_app/components/probe_component.rb +15 -0
  45. data/minitest/form_for_test.rb +2 -2
  46. data/minitest/hydration_test.rb +2 -2
  47. data/minitest/navigation_guard_test.rb +65 -0
  48. data/minitest/picoruby_helper_test.rb +236 -0
  49. data/minitest/schema_test.rb +47 -0
  50. data/minitest/session_epoch_test.rb +122 -0
  51. data/minitest/sig_tags_test.rb +30 -0
  52. data/minitest/ssr_database_test.rb +78 -0
  53. data/minitest/ssr_reload_test.rb +106 -0
  54. data/minitest/ssr_test.rb +41 -0
  55. data/minitest/testing_ensure_compiled_test.rb +52 -0
  56. data/minitest/validations_test.rb +35 -5
  57. data/minitest/view_context_test.rb +15 -15
  58. data/mrbgem.rake +2 -0
  59. data/mrblib/0_tags.rb +62 -0
  60. data/mrblib/cable.rb +1 -1
  61. data/mrblib/component.rb +226 -24
  62. data/mrblib/db.rb +3116 -0
  63. data/mrblib/error_boundary.rb +25 -19
  64. data/mrblib/file_upload.rb +17 -7
  65. data/mrblib/form_builder.rb +10 -10
  66. data/mrblib/funicular.rb +136 -17
  67. data/mrblib/http.rb +84 -107
  68. data/mrblib/model.rb +1178 -23
  69. data/mrblib/relation.rb +342 -0
  70. data/mrblib/router.rb +45 -4
  71. data/mrblib/styles.rb +122 -12
  72. data/mrblib/view_context.rb +3 -32
  73. data/sig/component.rbs +25 -4
  74. data/sig/db.rbs +328 -0
  75. data/sig/error_boundary.rbs +4 -4
  76. data/sig/funicular.rbs +5 -0
  77. data/sig/http.rbs +8 -21
  78. data/sig/model.rbs +101 -7
  79. data/sig/relation.rbs +44 -0
  80. data/sig/router.rbs +1 -0
  81. data/sig/styles.rbs +19 -5
  82. data/sig/tags.rbs +54 -0
  83. data/sig/view_context.rbs +47 -34
  84. metadata +23 -2
  85. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm.map +0 -1
data/mrblib/component.rb CHANGED
@@ -52,9 +52,60 @@ module Funicular
52
52
  end
53
53
  end
54
54
 
55
+ include Tags
56
+
55
57
  attr_accessor :props, :vdom, :dom_element, :mounted, :runtime, :children, :current_children
56
58
  attr_reader :refs
57
59
 
60
+ # Opt out of DSL collision detection for the given method names. The
61
+ # shadowed tag remains reachable through tag(:name, ...).
62
+ def self.allow_dsl_override(*names)
63
+ @dsl_overrides ||= [] #: Array[Symbol]
64
+ @dsl_overrides.concat(names)
65
+ end
66
+
67
+ def self.dsl_overrides
68
+ @dsl_overrides ||= [] #: Array[Symbol]
69
+ end
70
+
71
+ # Layer 1: catches `def`, `define_method`, and `alias` in subclasses at
72
+ # class-definition time. attr_* does not fire this hook on mruby, and
73
+ # included modules never do; validate_dsl_conflicts! covers those.
74
+ def self.method_added(name)
75
+ if self != Funicular::Component && Funicular::Tags::RESERVED_DSL[name] && !dsl_overrides.include?(name)
76
+ kind = Funicular::Tags::RESERVED_DSL[name] == :tag ? "<#{name}> tag helper" : "##{name} helper"
77
+ raise Funicular::DSLCollisionError,
78
+ "#{self}##{name} collides with the Funicular DSL (#{kind}). " \
79
+ "Rename it (e.g. `#{name}_value`), or declare `allow_dsl_override :#{name}` " \
80
+ "and use `tag(:#{name}, ...)` to emit the element."
81
+ end
82
+ super
83
+ end
84
+
85
+ # Layer 2: once per class, sweep methods the method_added hook cannot
86
+ # see (attr_* on mruby, user-included modules).
87
+ def self.validate_dsl_conflicts!
88
+ return if @dsl_validated
89
+ if instance_method(:render).arity != 0
90
+ raise Funicular::DSLCollisionError,
91
+ "#{self}#render must not take parameters as of Funicular 0.4.0: " \
92
+ "delete the parameter (`def render`) and drop the `h.` receivers " \
93
+ "inside; tags are bareword methods on the component now."
94
+ end
95
+ ancestors.each do |mod|
96
+ break if mod == Funicular::Component || mod == Funicular::Tags
97
+ next unless mod.is_a?(Module)
98
+ methods = mod.instance_methods(false) + mod.private_instance_methods(false)
99
+ offenders = methods.select { |m| Funicular::Tags::RESERVED_DSL[m] } - dsl_overrides
100
+ next if offenders.empty?
101
+ raise Funicular::DSLCollisionError,
102
+ "#{mod} defines methods that collide with the Funicular DSL: " \
103
+ "#{offenders.map { |m| m.to_s }.join(', ')}. Rename them, or declare " \
104
+ "`allow_dsl_override` and use `tag(...)` to emit the element."
105
+ end
106
+ @dsl_validated = true
107
+ end
108
+
58
109
  def initialize(props = {})
59
110
  @props = props
60
111
  @state = initialize_state || {}
@@ -93,7 +144,7 @@ module Funicular
93
144
  end
94
145
 
95
146
  def styles
96
- @style_accessor ||= StyleAccessor.new(self.class.styles_definitions)
147
+ @style_accessor ||= self.class.style_accessor_class.new(self.class.styles_definitions)
97
148
  end
98
149
 
99
150
  # Override this method in subclasses to define initial state
@@ -120,6 +171,72 @@ module Funicular
120
171
  self
121
172
  end
122
173
 
174
+ # Bind a state key to a local-database Relation (docs decision 10):
175
+ # the block runs once now, and again after every change event on the
176
+ # relation's table. Each evaluation re-subscribes (a branchy block
177
+ # may return a different model's relation this time) and patches
178
+ # state[key], triggering a re-render. Subscriptions die with the
179
+ # component (see unmount), even when a lifecycle hook raises.
180
+ def watch(key, &block)
181
+ unless block
182
+ raise ArgumentError, "watch requires a block returning a Relation"
183
+ end
184
+ evaluate_watch(key, block)
185
+ nil
186
+ end
187
+
188
+ # Framework use (the subscription re-enters here on every event).
189
+ def evaluate_watch(key, block)
190
+ relation = block.call
191
+ unless relation.is_a?(Funicular::Relation)
192
+ raise ArgumentError,
193
+ "the watch(:#{key}) block must return a Funicular::Relation, " \
194
+ "got #{relation.class}; for hashes, counts, or raw SQL, " \
195
+ "subscribe with Model.on_change and patch state yourself"
196
+ end
197
+ # Materialize BEFORE touching subscriptions: if the query raises,
198
+ # neither a fresh subscription leaks (initial watch) nor the
199
+ # previous healthy one is lost (re-evaluation). Next-tick delivery
200
+ # guarantees no change event interleaves with this evaluation.
201
+ records = relation.to_a
202
+ source = relation.__event_source
203
+ watches = (@__watches ||= {}) # steep:ignore UnannotatedEmptyCollection
204
+ old = watches[key]
205
+ Funicular::DB.unsubscribe(old) if old
206
+ component = self
207
+ watches[key] = Funicular::DB.subscribe(source[0], source[1]) do |r, t|
208
+ component.evaluate_watch(key, block)
209
+ end
210
+ if @mounted
211
+ # The normal update contract: patch runs the update lifecycle
212
+ # (component_will_update/component_updated/component_raised),
213
+ # normalizes values, and re-renders. The bus's next-tick
214
+ # delivery guarantees we are never inside another update here.
215
+ patch(key => records)
216
+ else
217
+ # Before mount there is no update lifecycle to honor yet: land
218
+ # the initial data directly so state is ready for the first
219
+ # render.
220
+ @state = @state.merge({ key => records })
221
+ @state_accessor = nil
222
+ end
223
+ nil
224
+ end
225
+
226
+ def cleanup_watches
227
+ watches = @__watches
228
+ return nil unless watches
229
+ keys = watches.keys
230
+ keys_size = keys.size
231
+ i = 0
232
+ while i < keys_size
233
+ Funicular::DB.unsubscribe(watches[keys[i]])
234
+ i += 1
235
+ end
236
+ @__watches = {}
237
+ nil
238
+ end
239
+
123
240
  # Load all registered suspense data
124
241
  # Called automatically in component_mounted if suspense definitions exist
125
242
  def load_suspense_data
@@ -230,21 +347,21 @@ module Funicular
230
347
  # ) do
231
348
  # div { user.name }
232
349
  # end
233
- def render_suspense(h, name, fallback:, error: nil, &block)
350
+ def render_suspense(name, fallback:, error: nil, &block)
234
351
  current_children = @current_children
235
352
  child_count_before = current_children&.size
236
353
  result = nil
237
354
 
238
355
  if @suspense_states[name] == :rejected
239
356
  result = if error
240
- error.call(h, @suspense_errors[name])
357
+ error.call(@suspense_errors[name])
241
358
  else
242
- fallback.call(h)
359
+ fallback.call
243
360
  end
244
361
  elsif suspense_loading?(name)
245
- result = fallback.call(h)
362
+ result = fallback.call
246
363
  else
247
- result = block.call(h, resources)
364
+ result = block.call(resources)
248
365
  end
249
366
 
250
367
  if current_children && current_children.size == child_count_before
@@ -254,17 +371,25 @@ module Funicular
254
371
  result
255
372
  end
256
373
 
257
- # Class methods for styles DSL
374
+ # Class methods for styles DSL. The block is instance_exec'd on a
375
+ # StyleBuilder cleanroom so barewords define styles; it also receives
376
+ # the builder for the explicit `styles { |css| css.define(...) }` form.
258
377
  def self.styles(&block)
259
378
  builder = StyleBuilder.new
260
- block.call(builder)
379
+ builder.instance_exec(builder, &block) # steep:ignore
261
380
  @styles_definitions = builder.to_definitions
381
+ @style_accessor_class = nil
262
382
  end
263
383
 
264
384
  def self.styles_definitions
265
385
  @styles_definitions ||= {}
266
386
  end
267
387
 
388
+ # Per-class accessor with one real method per declared style name.
389
+ def self.style_accessor_class
390
+ @style_accessor_class ||= StyleAccessor.accessor_for(styles_definitions)
391
+ end
392
+
268
393
  # Suspense DSL - register async data loaders
269
394
  #
270
395
  # @param name [Symbol] Name of the suspense data (becomes accessible as method)
@@ -349,6 +474,10 @@ module Funicular
349
474
 
350
475
  component_mounted if respond_to?(:component_mounted)
351
476
  rescue => e
477
+ # A failed mount never reaches unmount (@mounted is still
478
+ # false), so watch subscriptions taken before/during the mount
479
+ # must be released here or they would pin the dead component.
480
+ cleanup_watches
352
481
  component_raised(e) if respond_to?(:component_raised)
353
482
  raise e
354
483
  end
@@ -364,9 +493,12 @@ module Funicular
364
493
  # window.__FUNICULAR_STATE__ before calling this.
365
494
  def hydrate(dom_element)
366
495
  return if @mounted
367
- raise "hydrate: missing server DOM element" unless dom_element
368
496
 
369
497
  begin
498
+ # Inside the begin so a nil element takes the same cleanup path
499
+ # (releasing watch subscriptions) as every other hydrate failure.
500
+ raise "hydrate: missing server DOM element" unless dom_element
501
+
370
502
  component_will_mount if respond_to?(:component_will_mount)
371
503
 
372
504
  # The router/start sets the container; without it, unmount could not
@@ -408,11 +540,30 @@ module Funicular
408
540
 
409
541
  component_mounted if respond_to?(:component_mounted)
410
542
  rescue => e
543
+ # Same as mount: a failed hydrate cannot be unmounted, so the
544
+ # watch subscriptions must not outlive it.
545
+ cleanup_watches
411
546
  component_raised(e) if respond_to?(:component_raised)
412
547
  raise e
413
548
  end
414
549
  end
415
550
 
551
+ # Navigation guard: return a String message to ask the user for
552
+ # confirmation before this component is navigated away from (router
553
+ # navigation, browser back/forward, reload, tab close), or nil to
554
+ # allow leaving freely. Consulted by the router and its beforeunload
555
+ # listener; override in components with unsaved work:
556
+ #
557
+ # def navigation_guard
558
+ # state[:dirty] ? "Unsaved changes will be lost. Leave?" : nil
559
+ # end
560
+ #
561
+ # Must not suspend (no fetch/HTTP): the beforeunload path runs it on
562
+ # the synchronous JS event dispatch stack.
563
+ def navigation_guard
564
+ nil
565
+ end
566
+
416
567
  # Unmount component from DOM
417
568
  def unmount
418
569
  return unless @mounted
@@ -443,23 +594,29 @@ module Funicular
443
594
  rescue => e
444
595
  component_raised(e) if respond_to?(:component_raised)
445
596
  raise e
597
+ ensure
598
+ # Watch subscriptions die with the component NO MATTER WHAT: a
599
+ # raising lifecycle hook must not leave a zombie watcher
600
+ # patching an unmounted component (docs decision 10).
601
+ cleanup_watches
446
602
  end
447
603
  end
448
604
 
449
605
  # Override this method in subclasses to define render logic
450
- def render(h)
451
- raise "Subclasses must implement render(h)"
606
+ def render
607
+ raise "Subclasses must implement render"
452
608
  end
453
609
 
454
610
  # Build VDOM tree from render method
455
611
  # Called by VDOM::Renderer, Differ, and Patcher
456
612
  def build_vdom
613
+ self.class.validate_dsl_conflicts!
457
614
  previous_rendering = @rendering
458
615
  previous_children = @current_children
459
616
  @rendering = true
460
617
  @current_children = nil
461
618
  begin
462
- result = render(ViewContext.new(self))
619
+ result = render
463
620
  ensure
464
621
  @rendering = previous_rendering
465
622
  @current_children = previous_children
@@ -499,6 +656,7 @@ module Funicular
499
656
  begin
500
657
  self.send(value, event)
501
658
  rescue => e
659
+ report_handler_error(event_name, "##{value}", e)
502
660
  component_raised(e) if respond_to?(:component_raised)
503
661
  raise e
504
662
  end
@@ -515,6 +673,7 @@ module Funicular
515
673
  value.call(event)
516
674
  end
517
675
  rescue => e
676
+ report_handler_error(event_name, "##{value.name}", e)
518
677
  component_raised(e) if respond_to?(:component_raised)
519
678
  raise e
520
679
  end
@@ -531,6 +690,7 @@ module Funicular
531
690
  value.call(event)
532
691
  end
533
692
  rescue => e
693
+ report_handler_error(event_name, "(proc)", e)
534
694
  component_raised(e) if respond_to?(:component_raised)
535
695
  raise e
536
696
  end
@@ -563,6 +723,18 @@ module Funicular
563
723
  end
564
724
  end
565
725
 
726
+ # Name the culprit before an event handler error is re-raised into
727
+ # the JS bridge, where "Callback <id>: ArgumentError: ..." carries no
728
+ # hint of which component or handler it came from. Uses puts so the
729
+ # message reaches the browser console (same idiom as the
730
+ # ErrorBoundary logger). Never raises itself.
731
+ def report_handler_error(event_name, handler, error)
732
+ puts "[Funicular] #{self.class}#{handler} (on#{event_name}) raised " \
733
+ "#{error.class}: #{error.message}"
734
+ rescue
735
+ nil
736
+ end
737
+
566
738
  # Collect ref elements from VDOM
567
739
  # Called by VDOM::Renderer and Patcher
568
740
  def collect_refs(dom_element, vnode, refs_map = {})
@@ -622,9 +794,45 @@ module Funicular
622
794
  normalize_vnode(value)
623
795
  end
624
796
 
797
+ # Internal element factory shared by the Tags mixin, FormBuilder, and
798
+ # the framework helpers. One instance per component; ViewContext itself
799
+ # is stateless (the render cursor lives on the component).
800
+ def __view__
801
+ @__view__ ||= ViewContext.new(self)
802
+ end
803
+
804
+ def routes
805
+ @runtime.routes
806
+ end
807
+
808
+ # Bareword DSL helpers available inside render (self is the component).
809
+ def component(component_class, props = {}, &block)
810
+ __view__.component(component_class, props, &block)
811
+ end
812
+
813
+ def form_for(model_key, options = {}, &block)
814
+ build_form_for(__view__, model_key, options, &block)
815
+ end
816
+
817
+ def link_to(path, **options, &block)
818
+ build_link_to(__view__, path, **options, &block)
819
+ end
820
+
821
+ def button_to(path, method: :post, **options, &block)
822
+ build_button_to(__view__, path, method: method, **options, &block)
823
+ end
824
+
825
+ def suspense(name, fallback:, error: nil, &block)
826
+ render_suspense(name, fallback: fallback, error: error, &block)
827
+ end
828
+
625
829
  def add_child_from_view(child)
830
+ unless @rendering
831
+ raise Funicular::RenderContextError,
832
+ "view DSL called outside render (tags may only be used while the component is rendering)"
833
+ end
626
834
  current_children = @current_children
627
- return unless @rendering && current_children
835
+ return unless current_children
628
836
 
629
837
  normalized = normalize_vnode(child)
630
838
  current_children << normalized if normalized
@@ -654,8 +862,8 @@ module Funicular
654
862
  ->(event) { event.preventDefault }
655
863
  end
656
864
 
657
- h.form({ onsubmit: submit_handler, class: form_class }.merge(options)) do |hh|
658
- builder = Funicular::FormBuilder.new(self, hh, model_key, options)
865
+ h.form({ onsubmit: submit_handler, class: form_class }.merge(options)) do
866
+ builder = Funicular::FormBuilder.new(self, h, model_key, options)
659
867
  block.call(builder)
660
868
  end
661
869
  end
@@ -881,15 +1089,9 @@ module Funicular
881
1089
  end
882
1090
 
883
1091
  def event_target(event)
884
- target = nil
885
- begin
886
- target = event[:target]
887
- rescue
888
- target = nil
889
- end
890
- return target if target
891
-
892
- event.target if event.respond_to?(:target)
1092
+ event[:target]
1093
+ rescue
1094
+ nil
893
1095
  end
894
1096
 
895
1097
  def add_form_field_value(data, field)