funicular 0.4.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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +429 -1
  3. data/demo/local_notes.html +207 -0
  4. data/docs/architecture.md +181 -3
  5. data/docs/local_database.md +1035 -0
  6. data/lib/funicular/assets/funicular.rb +14 -0
  7. data/lib/funicular/configuration.rb +65 -0
  8. data/lib/funicular/epoch_header.rb +69 -0
  9. data/lib/funicular/epoch_stamping.rb +66 -0
  10. data/lib/funicular/helpers/picoruby_helper.rb +96 -1
  11. data/lib/funicular/railtie.rb +30 -0
  12. data/lib/funicular/schema.rb +45 -12
  13. data/lib/funicular/session_epoch.rb +110 -0
  14. data/lib/funicular/ssr/runtime.rb +57 -12
  15. data/lib/funicular/ssr.rb +25 -0
  16. data/lib/funicular/testing/node_runner.mjs +19 -0
  17. data/lib/funicular/testing.rb +47 -0
  18. data/lib/funicular/vendor/mrbc/VERSION +1 -1
  19. data/lib/funicular/vendor/mrbc/mrbc.js +69 -115
  20. data/lib/funicular/vendor/mrbc/mrbc.wasm +0 -0
  21. data/lib/funicular/vendor/picoruby/VERSION +1 -1
  22. data/lib/funicular/vendor/picoruby/debug/picoruby.js +170 -120
  23. data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
  24. data/lib/funicular/vendor/picoruby/dist/picoruby.js +1 -1
  25. data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
  26. data/lib/funicular/vendor/picoruby-test-node/VERSION +1 -1
  27. data/lib/funicular/vendor/picoruby-test-node/picoruby.js +2 -7201
  28. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
  29. data/lib/funicular/version.rb +1 -1
  30. data/lib/funicular.rb +1 -0
  31. data/lib/tasks/funicular.rake +10 -2
  32. data/minitest/callback_error_visibility_test.rb +48 -0
  33. data/minitest/configuration_test.rb +78 -0
  34. data/minitest/dsl_test.rb +27 -0
  35. data/minitest/epoch_header_test.rb +149 -0
  36. data/minitest/epoch_stamping_test.rb +225 -0
  37. data/minitest/fixtures/funicular_app/components/probe_component.rb +15 -0
  38. data/minitest/navigation_guard_test.rb +65 -0
  39. data/minitest/picoruby_helper_test.rb +236 -0
  40. data/minitest/schema_test.rb +47 -0
  41. data/minitest/session_epoch_test.rb +122 -0
  42. data/minitest/ssr_database_test.rb +78 -0
  43. data/minitest/ssr_reload_test.rb +106 -0
  44. data/minitest/ssr_test.rb +41 -0
  45. data/minitest/testing_ensure_compiled_test.rb +52 -0
  46. data/minitest/validations_test.rb +35 -5
  47. data/mrbgem.rake +2 -0
  48. data/mrblib/cable.rb +1 -1
  49. data/mrblib/component.rb +113 -1
  50. data/mrblib/db.rb +3116 -0
  51. data/mrblib/file_upload.rb +17 -7
  52. data/mrblib/funicular.rb +136 -17
  53. data/mrblib/http.rb +84 -107
  54. data/mrblib/model.rb +1178 -23
  55. data/mrblib/relation.rb +342 -0
  56. data/mrblib/router.rb +45 -4
  57. data/mrblib/styles.rb +20 -0
  58. data/sig/component.rbs +7 -0
  59. data/sig/db.rbs +328 -0
  60. data/sig/funicular.rbs +5 -0
  61. data/sig/http.rbs +8 -21
  62. data/sig/model.rbs +101 -7
  63. data/sig/relation.rbs +44 -0
  64. data/sig/router.rbs +1 -0
  65. data/sig/styles.rbs +1 -0
  66. metadata +19 -2
  67. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm.map +0 -1
@@ -15,6 +15,7 @@ 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
@@ -36,6 +37,8 @@ module Funicular
36
37
  router
37
38
  0_validations
38
39
  1_validators
40
+ db
41
+ relation
39
42
  model
40
43
  store
41
44
  store_singleton
@@ -71,33 +74,75 @@ module Funicular
71
74
  # In that case we rescue and continue: the AR constant is already defined
72
75
  # and is all that load_schemas needs (it ignores the hash on the server).
73
76
  #
74
- # 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.
75
82
  def boot!(source_dir)
76
- load_framework!
77
- 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
78
86
 
79
- files = Funicular::Compiler.source_files(source_dir.to_s)
80
- files.each do |file|
81
- begin
82
- Kernel.load(file)
83
- rescue TypeError => e
84
- # Funicular model name conflicts with an already-loaded AR model.
85
- # The constant is already defined; safe to skip.
86
- 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)
87
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) ]
88
122
  end
89
- @app_loaded = true
90
123
  end
91
124
 
92
125
  # Test/escape hatch: forget loaded application state so a different
93
126
  # app (or a reload) can be booted. Does not unload the framework.
94
127
  def reset_app!
95
128
  @app_loaded = false
129
+ @sources_snapshot = nil
96
130
  end
97
131
 
98
132
  def framework_loaded?
99
133
  !!@framework_loaded
100
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
101
146
  end
102
147
  end
103
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.2
1
+ 4.0.3