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
@@ -15,12 +15,14 @@ module Funicular
15
15
  # (Funicular.start, Router#start, FileUpload.mount, Debug) no-ops.
16
16
  module Runtime
17
17
  MRBLIB_DIR = File.expand_path("../../../mrblib", __dir__)
18
+ BOOT_MUTEX = Mutex.new
18
19
 
19
20
  # Load order is by dependency at *class-body* evaluation time. Most
20
21
  # files reference JS / other classes only inside methods, so only a
21
22
  # few real dependencies exist (vdom before html_serializer; styles and
22
23
  # vdom before component; component before error_boundary).
23
24
  LOAD_ORDER = %w[
25
+ 0_tags
24
26
  environment_inquirer
25
27
  vdom
26
28
  html_serializer
@@ -35,6 +37,8 @@ module Funicular
35
37
  router
36
38
  0_validations
37
39
  1_validators
40
+ db
41
+ relation
38
42
  model
39
43
  store
40
44
  store_singleton
@@ -70,33 +74,75 @@ module Funicular
70
74
  # In that case we rescue and continue: the AR constant is already defined
71
75
  # and is all that load_schemas needs (it ignores the hash on the server).
72
76
  #
73
- # Loaded once per process. Restart the server to pick up changes.
77
+ # Loaded once per process. With auto_reload (the railtie enables
78
+ # it in development), edited sources are picked up on the next
79
+ # render: reloading re-runs the initializer, and the server-side
80
+ # Funicular.start builds a fresh router, so routes refresh too.
81
+ # Without it, restart the server to pick up changes.
74
82
  def boot!(source_dir)
75
- load_framework!
76
- return if @app_loaded
83
+ # Fast path for the steady production/test state: everything
84
+ # loaded and nobody watching for changes, no lock to take.
85
+ return if @framework_loaded && @app_loaded && !auto_reload
77
86
 
78
- files = Funicular::Compiler.source_files(source_dir.to_s)
79
- files.each do |file|
80
- begin
81
- Kernel.load(file)
82
- rescue TypeError => e
83
- # Funicular model name conflicts with an already-loaded AR model.
84
- # The constant is already defined; safe to skip.
85
- warn "[Funicular SSR] Skipped #{File.basename(file)}: #{e.message}"
87
+ # Concurrent renders (Puma threads in development) must not
88
+ # interleave two reloads: constants and the router would pass
89
+ # through inconsistent states mid-request.
90
+ BOOT_MUTEX.synchronize do
91
+ load_framework!
92
+ if @app_loaded
93
+ next unless auto_reload && sources_changed?(source_dir)
86
94
  end
95
+
96
+ files = Funicular::Compiler.source_files(source_dir.to_s)
97
+ files.each do |file|
98
+ begin
99
+ Kernel.load(file)
100
+ rescue TypeError => e
101
+ # Funicular model name conflicts with an already-loaded AR model.
102
+ # The constant is already defined; safe to skip.
103
+ warn "[Funicular SSR] Skipped #{File.basename(file)}: #{e.message}"
104
+ end
105
+ end
106
+ @app_loaded = true
107
+ @sources_snapshot = sources_snapshot(source_dir)
108
+ end
109
+ end
110
+
111
+ # Reload edited app sources on the next boot! instead of
112
+ # requiring a server restart (meant for development).
113
+ attr_accessor :auto_reload
114
+
115
+ def sources_changed?(source_dir)
116
+ @sources_snapshot != sources_snapshot(source_dir)
117
+ end
118
+
119
+ def sources_snapshot(source_dir)
120
+ Funicular::Compiler.source_files(source_dir.to_s).map do |file|
121
+ [ file, mtime_or_nil(file) ]
87
122
  end
88
- @app_loaded = true
89
123
  end
90
124
 
91
125
  # Test/escape hatch: forget loaded application state so a different
92
126
  # app (or a reload) can be booted. Does not unload the framework.
93
127
  def reset_app!
94
128
  @app_loaded = false
129
+ @sources_snapshot = nil
95
130
  end
96
131
 
97
132
  def framework_loaded?
98
133
  !!@framework_loaded
99
134
  end
135
+
136
+ private
137
+
138
+ # The mtime read races with editors deleting or renaming files
139
+ # mid-edit; a vanished file counts as nil instead of breaking
140
+ # the render.
141
+ def mtime_or_nil(file)
142
+ File.mtime(file).to_f
143
+ rescue SystemCallError
144
+ nil
145
+ end
100
146
  end
101
147
  end
102
148
  end
data/lib/funicular/ssr.rb CHANGED
@@ -37,6 +37,31 @@ module Funicular
37
37
  { html: html, state: state, component: component_class }
38
38
  end
39
39
 
40
+ # Render one component to an HTML string with no route lookup: for
41
+ # embedding a Funicular component into an otherwise server-rendered
42
+ # (ERB) page -- a shared site header, say -- so the markup has a
43
+ # single source of truth. The component is named by string and
44
+ # resolved after boot!, because host apps keep app/funicular out of
45
+ # Rails autoloading and the constant does not exist before loading.
46
+ # Handlers are not bound and no hydration happens: the output is
47
+ # static HTML (links still work; onclick and friends do not).
48
+ def self.render_component(component_name, props: {}, state: {}, source_dir: nil)
49
+ Runtime.boot!(source_dir || default_source_dir)
50
+
51
+ router = Funicular.router
52
+ raise "Funicular router is not configured; check app/funicular/initializer.rb" unless router
53
+
54
+ component_class = Object.const_get(component_name.to_s)
55
+ unless component_class.is_a?(Class) && component_class < Funicular::Component
56
+ raise ArgumentError, "#{component_name} is not a Funicular::Component subclass"
57
+ end
58
+
59
+ instance = component_class.new(symbolize_keys(props))
60
+ instance.runtime = Funicular::Runtime.new(router)
61
+ instance.seed_state(state)
62
+ Funicular::VDOM::HTMLSerializer.serialize(instance.build_vdom, instance.runtime)
63
+ end
64
+
40
65
  def self.default_source_dir
41
66
  raise "source_dir is required outside Rails" unless defined?(Rails) && Rails.respond_to?(:root)
42
67
  Rails.root.join("app", "funicular")
@@ -105,6 +105,15 @@ module Funicular
105
105
  report(!actual.nil?, "Expected selector #{selector.inspect} to exist", selector, actual)
106
106
  end
107
107
 
108
+ def assert_no_selector(selector)
109
+ actual = query(selector)
110
+ report(actual.nil?, "Expected selector #{selector.inspect} to be absent", selector, actual)
111
+ end
112
+
113
+ def selector_count(selector)
114
+ JS.eval("document.querySelectorAll(" + JSON.generate(selector) + ").length").to_i
115
+ end
116
+
108
117
  def text(selector = nil)
109
118
  target = selector ? query(selector) : document.body
110
119
  target ? target[:textContent].to_s : ''
@@ -120,6 +129,16 @@ module Funicular
120
129
  )
121
130
  end
122
131
 
132
+ def assert_no_text(expected, selector = nil)
133
+ actual = text(selector)
134
+ report(
135
+ !actual.include?(expected.to_s),
136
+ "Expected text not to include #{expected.to_s.inspect}",
137
+ expected.to_s,
138
+ actual
139
+ )
140
+ end
141
+
123
142
  def dispatch(selector, event_type)
124
143
  script = "document.querySelector(" + JSON.generate(selector) + ")" \
125
144
  + ".dispatchEvent(new Event(" + JSON.generate(event_type) \
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "testing/node_runner"
4
+ require_relative "plugin"
5
+ require_relative "compiler"
4
6
 
5
7
  module Funicular
6
8
  module Testing
@@ -8,6 +10,51 @@ module Funicular
8
10
  NodeRunner.new(**options).run
9
11
  end
10
12
 
13
+ # One-call test-environment setup: syncs plugin assets and compiles
14
+ # the client bundle when it is missing or older than any source or
15
+ # plugin file. Development recompiles through the middleware and
16
+ # production through assets:precompile, but the test environment
17
+ # serves whatever app.mrb is lying around; call this once from
18
+ # test_helper and rely on the mtime check to keep it cheap.
19
+ #
20
+ # Funicular::Testing.ensure_compiled! # Rails.root
21
+ # Funicular::Testing.ensure_compiled!(force: true)
22
+ #
23
+ # Returns the path of the compiled bundle.
24
+ def self.ensure_compiled!(root: nil, force: false, debug_mode: true)
25
+ root = Pathname.new(root || default_root)
26
+ registry = Funicular::Plugin::Registry.new(root)
27
+ registry.validate!
28
+ registry.sync_assets
29
+
30
+ source_dir = root.join("app", "funicular").to_s
31
+ output_file = root.join("app", "assets", "builds", "app.mrb").to_s
32
+ sources = registry.local_source_files + Funicular::Compiler.source_files(source_dir)
33
+
34
+ if force || stale?(output_file, sources)
35
+ Funicular::Compiler.new(
36
+ source_dir: source_dir,
37
+ output_file: output_file,
38
+ debug_mode: debug_mode,
39
+ prepend_source_files: registry.local_source_files
40
+ ).compile
41
+ end
42
+ output_file
43
+ end
44
+
45
+ # A bundle is stale when it does not exist or any source file was
46
+ # modified after it was built.
47
+ def self.stale?(output_file, source_files)
48
+ return true unless File.exist?(output_file)
49
+ build_time = File.mtime(output_file)
50
+ source_files.any? { |f| File.exist?(f) && File.mtime(f) > build_time }
51
+ end
52
+
53
+ def self.default_root
54
+ raise ArgumentError, "root is required outside Rails" unless defined?(Rails) && Rails.respond_to?(:root)
55
+ Rails.root
56
+ end
57
+
11
58
  def self.assert_picotests(test_case, result, print_summary: true)
12
59
  puts result.picotest_summary if print_summary
13
60
  test_case.assert result.success?, result.output
@@ -1 +1 @@
1
- 4.0.1
1
+ 4.0.3