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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +429 -1
- data/demo/local_notes.html +207 -0
- data/docs/architecture.md +181 -3
- data/docs/local_database.md +1035 -0
- data/lib/funicular/assets/funicular.rb +14 -0
- data/lib/funicular/configuration.rb +65 -0
- data/lib/funicular/epoch_header.rb +69 -0
- data/lib/funicular/epoch_stamping.rb +66 -0
- data/lib/funicular/helpers/picoruby_helper.rb +96 -1
- data/lib/funicular/railtie.rb +30 -0
- data/lib/funicular/schema.rb +45 -12
- data/lib/funicular/session_epoch.rb +110 -0
- data/lib/funicular/ssr/runtime.rb +57 -12
- data/lib/funicular/ssr.rb +25 -0
- data/lib/funicular/testing/node_runner.mjs +19 -0
- data/lib/funicular/testing.rb +47 -0
- data/lib/funicular/vendor/mrbc/VERSION +1 -1
- data/lib/funicular/vendor/mrbc/mrbc.js +69 -115
- data/lib/funicular/vendor/mrbc/mrbc.wasm +0 -0
- data/lib/funicular/vendor/picoruby/VERSION +1 -1
- data/lib/funicular/vendor/picoruby/debug/picoruby.js +170 -120
- data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby/dist/picoruby.js +1 -1
- data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby-test-node/VERSION +1 -1
- data/lib/funicular/vendor/picoruby-test-node/picoruby.js +2 -7201
- data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
- data/lib/funicular/version.rb +1 -1
- data/lib/funicular.rb +1 -0
- data/lib/tasks/funicular.rake +10 -2
- data/minitest/callback_error_visibility_test.rb +48 -0
- data/minitest/configuration_test.rb +78 -0
- data/minitest/dsl_test.rb +27 -0
- data/minitest/epoch_header_test.rb +149 -0
- data/minitest/epoch_stamping_test.rb +225 -0
- data/minitest/fixtures/funicular_app/components/probe_component.rb +15 -0
- data/minitest/navigation_guard_test.rb +65 -0
- data/minitest/picoruby_helper_test.rb +236 -0
- data/minitest/schema_test.rb +47 -0
- data/minitest/session_epoch_test.rb +122 -0
- data/minitest/ssr_database_test.rb +78 -0
- data/minitest/ssr_reload_test.rb +106 -0
- data/minitest/ssr_test.rb +41 -0
- data/minitest/testing_ensure_compiled_test.rb +52 -0
- data/minitest/validations_test.rb +35 -5
- data/mrbgem.rake +2 -0
- data/mrblib/cable.rb +1 -1
- data/mrblib/component.rb +113 -1
- data/mrblib/db.rb +3116 -0
- data/mrblib/file_upload.rb +17 -7
- data/mrblib/funicular.rb +136 -17
- data/mrblib/http.rb +84 -107
- data/mrblib/model.rb +1178 -23
- data/mrblib/relation.rb +342 -0
- data/mrblib/router.rb +45 -4
- data/mrblib/styles.rb +20 -0
- data/sig/component.rbs +7 -0
- data/sig/db.rbs +328 -0
- data/sig/funicular.rbs +5 -0
- data/sig/http.rbs +8 -21
- data/sig/model.rbs +101 -7
- data/sig/relation.rbs +44 -0
- data/sig/router.rbs +1 -0
- data/sig/styles.rbs +1 -0
- metadata +19 -2
- data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm.map +0 -1
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
|
|
6
|
+
require "test_helper"
|
|
7
|
+
|
|
8
|
+
# Covers the freshness decision behind Funicular::Testing.ensure_compiled!.
|
|
9
|
+
# The actual compile (Node + vendored mrbc) is integration-only, matching
|
|
10
|
+
# the compiler tests' scope.
|
|
11
|
+
class TestingEnsureCompiledTest < Minitest::Test
|
|
12
|
+
def test_stale_when_bundle_is_missing
|
|
13
|
+
Dir.mktmpdir do |dir|
|
|
14
|
+
assert Funicular::Testing.stale?(File.join(dir, "app.mrb"), [])
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def test_fresh_when_bundle_is_newer_than_every_source
|
|
19
|
+
Dir.mktmpdir do |dir|
|
|
20
|
+
source = File.join(dir, "component.rb")
|
|
21
|
+
output = File.join(dir, "app.mrb")
|
|
22
|
+
File.write(source, "")
|
|
23
|
+
File.write(output, "")
|
|
24
|
+
File.utime(Time.now - 60, Time.now - 60, source)
|
|
25
|
+
|
|
26
|
+
refute Funicular::Testing.stale?(output, [ source ])
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def test_stale_when_any_source_is_newer_than_the_bundle
|
|
31
|
+
Dir.mktmpdir do |dir|
|
|
32
|
+
old_source = File.join(dir, "old.rb")
|
|
33
|
+
new_source = File.join(dir, "new.rb")
|
|
34
|
+
output = File.join(dir, "app.mrb")
|
|
35
|
+
[ old_source, new_source, output ].each { |f| File.write(f, "") }
|
|
36
|
+
File.utime(Time.now - 60, Time.now - 60, old_source)
|
|
37
|
+
File.utime(Time.now - 30, Time.now - 30, output)
|
|
38
|
+
File.utime(Time.now, Time.now, new_source)
|
|
39
|
+
|
|
40
|
+
assert Funicular::Testing.stale?(output, [ old_source, new_source ])
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def test_missing_sources_do_not_count
|
|
45
|
+
Dir.mktmpdir do |dir|
|
|
46
|
+
output = File.join(dir, "app.mrb")
|
|
47
|
+
File.write(output, "")
|
|
48
|
+
|
|
49
|
+
refute Funicular::Testing.stale?(output, [ File.join(dir, "gone.rb") ])
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -30,6 +30,35 @@ class ValidationsTest < Minitest::Test
|
|
|
30
30
|
assert_equal true, k.new("name" => "Alice").valid?
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
+
def test_uncompilable_format_regex_is_skipped_not_fatal
|
|
34
|
+
k = model_class
|
|
35
|
+
out, _err = capture_io do
|
|
36
|
+
k.register_schema_validations(
|
|
37
|
+
"name" => {
|
|
38
|
+
"format" => { "with" => "[", "flags" => "" },
|
|
39
|
+
"presence" => true
|
|
40
|
+
}
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
# The warning names the rejected pattern so it can be found among
|
|
44
|
+
# many schemas loading at boot.
|
|
45
|
+
assert_match(/Skipping format validator/, out)
|
|
46
|
+
assert_match(/pattern: \[/, out)
|
|
47
|
+
# The broken format validator is dropped; the rest still applies and
|
|
48
|
+
# schema registration does not raise.
|
|
49
|
+
blank = k.new("name" => "")
|
|
50
|
+
assert_equal false, blank.valid?
|
|
51
|
+
assert_equal true, k.new("name" => "Alice").valid?
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def test_schema_numericality_with_allow_nil_skips_nil
|
|
55
|
+
k = model_class({ "age" => false })
|
|
56
|
+
k.register_schema_validations("age" => { "numericality" => { "allow_nil" => true } })
|
|
57
|
+
|
|
58
|
+
assert_equal true, k.new("age" => nil).valid?
|
|
59
|
+
assert_equal false, k.new("age" => "abc").valid?
|
|
60
|
+
end
|
|
61
|
+
|
|
33
62
|
def test_length_maximum_and_minimum
|
|
34
63
|
k = model_class
|
|
35
64
|
k.class_eval { validates :name, length: { minimum: 2, maximum: 5 } }
|
|
@@ -170,14 +199,15 @@ class ValidationsTest < Minitest::Test
|
|
|
170
199
|
m = k.new("name" => "ok")
|
|
171
200
|
m.instance_variable_set("@id", 1)
|
|
172
201
|
|
|
173
|
-
got_success = :unset
|
|
174
202
|
got_result = :unset
|
|
175
|
-
|
|
176
|
-
|
|
203
|
+
got_error = :unset
|
|
204
|
+
m.update("name" => "") do |result, error|
|
|
177
205
|
got_result = result
|
|
206
|
+
got_error = error
|
|
178
207
|
end
|
|
179
208
|
|
|
180
|
-
|
|
181
|
-
|
|
209
|
+
# Unified 0.5.0 callback contract: (result, error), result nil on failure
|
|
210
|
+
assert_nil got_result
|
|
211
|
+
assert_equal ["can't be blank"], got_error[:name]
|
|
182
212
|
end
|
|
183
213
|
end
|
data/mrbgem.rake
CHANGED
|
@@ -7,7 +7,9 @@ MRuby::Gem::Specification.new('picoruby-funicular') do |spec|
|
|
|
7
7
|
spec.add_dependency 'picoruby-wasm'
|
|
8
8
|
end
|
|
9
9
|
spec.add_dependency 'picoruby-indexeddb'
|
|
10
|
+
spec.add_dependency 'picoruby-sqlite3'
|
|
10
11
|
spec.add_dependency 'picoruby-json'
|
|
12
|
+
spec.add_dependency 'picoruby-uri'
|
|
11
13
|
spec.add_dependency 'mruby-object-ext', gemdir: "#{MRUBY_ROOT}/mrbgems/picoruby-mruby/lib/mruby/mrbgems/mruby-object-ext"
|
|
12
14
|
spec.add_dependency 'mruby-hash-ext', gemdir: "#{MRUBY_ROOT}/mrbgems/picoruby-mruby/lib/mruby/mrbgems/mruby-hash-ext"
|
|
13
15
|
spec.add_dependency 'mruby-array-ext', gemdir: "#{MRUBY_ROOT}/mrbgems/picoruby-mruby/lib/mruby/mrbgems/mruby-array-ext"
|
data/mrblib/cable.rb
CHANGED
|
@@ -229,7 +229,7 @@ module Funicular
|
|
|
229
229
|
|
|
230
230
|
# Setup beforeunload handler to persist pending commands
|
|
231
231
|
def setup_beforeunload_handler
|
|
232
|
-
@beforeunload_callback_id = JS.global.addEventListener("beforeunload") do
|
|
232
|
+
@beforeunload_callback_id = JS.global.addEventListener("beforeunload", sync: true) do
|
|
233
233
|
save_pending_to_storage
|
|
234
234
|
end
|
|
235
235
|
end
|
data/mrblib/component.rb
CHANGED
|
@@ -171,6 +171,72 @@ module Funicular
|
|
|
171
171
|
self
|
|
172
172
|
end
|
|
173
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
|
+
|
|
174
240
|
# Load all registered suspense data
|
|
175
241
|
# Called automatically in component_mounted if suspense definitions exist
|
|
176
242
|
def load_suspense_data
|
|
@@ -408,6 +474,10 @@ module Funicular
|
|
|
408
474
|
|
|
409
475
|
component_mounted if respond_to?(:component_mounted)
|
|
410
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
|
|
411
481
|
component_raised(e) if respond_to?(:component_raised)
|
|
412
482
|
raise e
|
|
413
483
|
end
|
|
@@ -423,9 +493,12 @@ module Funicular
|
|
|
423
493
|
# window.__FUNICULAR_STATE__ before calling this.
|
|
424
494
|
def hydrate(dom_element)
|
|
425
495
|
return if @mounted
|
|
426
|
-
raise "hydrate: missing server DOM element" unless dom_element
|
|
427
496
|
|
|
428
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
|
+
|
|
429
502
|
component_will_mount if respond_to?(:component_will_mount)
|
|
430
503
|
|
|
431
504
|
# The router/start sets the container; without it, unmount could not
|
|
@@ -467,11 +540,30 @@ module Funicular
|
|
|
467
540
|
|
|
468
541
|
component_mounted if respond_to?(:component_mounted)
|
|
469
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
|
|
470
546
|
component_raised(e) if respond_to?(:component_raised)
|
|
471
547
|
raise e
|
|
472
548
|
end
|
|
473
549
|
end
|
|
474
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
|
+
|
|
475
567
|
# Unmount component from DOM
|
|
476
568
|
def unmount
|
|
477
569
|
return unless @mounted
|
|
@@ -502,6 +594,11 @@ module Funicular
|
|
|
502
594
|
rescue => e
|
|
503
595
|
component_raised(e) if respond_to?(:component_raised)
|
|
504
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
|
|
505
602
|
end
|
|
506
603
|
end
|
|
507
604
|
|
|
@@ -559,6 +656,7 @@ module Funicular
|
|
|
559
656
|
begin
|
|
560
657
|
self.send(value, event)
|
|
561
658
|
rescue => e
|
|
659
|
+
report_handler_error(event_name, "##{value}", e)
|
|
562
660
|
component_raised(e) if respond_to?(:component_raised)
|
|
563
661
|
raise e
|
|
564
662
|
end
|
|
@@ -575,6 +673,7 @@ module Funicular
|
|
|
575
673
|
value.call(event)
|
|
576
674
|
end
|
|
577
675
|
rescue => e
|
|
676
|
+
report_handler_error(event_name, "##{value.name}", e)
|
|
578
677
|
component_raised(e) if respond_to?(:component_raised)
|
|
579
678
|
raise e
|
|
580
679
|
end
|
|
@@ -591,6 +690,7 @@ module Funicular
|
|
|
591
690
|
value.call(event)
|
|
592
691
|
end
|
|
593
692
|
rescue => e
|
|
693
|
+
report_handler_error(event_name, "(proc)", e)
|
|
594
694
|
component_raised(e) if respond_to?(:component_raised)
|
|
595
695
|
raise e
|
|
596
696
|
end
|
|
@@ -623,6 +723,18 @@ module Funicular
|
|
|
623
723
|
end
|
|
624
724
|
end
|
|
625
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
|
+
|
|
626
738
|
# Collect ref elements from VDOM
|
|
627
739
|
# Called by VDOM::Renderer and Patcher
|
|
628
740
|
def collect_refs(dom_element, vnode, refs_map = {})
|