ruact 0.0.2 → 0.0.4

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 (131) hide show
  1. checksums.yaml +4 -4
  2. data/.codecov.yml +31 -0
  3. data/.github/workflows/ci.yml +160 -94
  4. data/.github/workflows/server-functions-bench.yml +54 -0
  5. data/.rubocop.yml +19 -1
  6. data/.rubocop_todo.yml +175 -0
  7. data/CHANGELOG.md +88 -5
  8. data/README.md +2 -0
  9. data/RELEASING.md +9 -3
  10. data/bench/server_functions_dispatch_bench.rb +309 -0
  11. data/bench/server_functions_dispatch_bench.results.md +121 -0
  12. data/docs/internal/README.md +9 -0
  13. data/docs/internal/decisions/server-functions-api.md +1779 -0
  14. data/lib/generators/ruact/install/install_generator.rb +43 -0
  15. data/lib/generators/ruact/install/templates/application.jsx.tt +1 -1
  16. data/lib/generators/ruact/install/templates/initializer.rb.tt +1 -1
  17. data/lib/ruact/client_manifest.rb +125 -12
  18. data/lib/ruact/configuration.rb +264 -23
  19. data/lib/ruact/controller.rb +459 -32
  20. data/lib/ruact/doctor.rb +34 -2
  21. data/lib/ruact/erb_preprocessor.rb +6 -6
  22. data/lib/ruact/errors.rb +100 -0
  23. data/lib/ruact/flight/serializer.rb +2 -2
  24. data/lib/ruact/html_converter.rb +131 -31
  25. data/lib/ruact/query.rb +107 -0
  26. data/lib/ruact/railtie.rb +220 -3
  27. data/lib/ruact/render_context.rb +30 -0
  28. data/lib/ruact/render_pipeline.rb +201 -59
  29. data/lib/ruact/routing.rb +81 -0
  30. data/lib/ruact/serializable.rb +11 -11
  31. data/lib/ruact/server.rb +341 -0
  32. data/lib/ruact/server_action.rb +131 -0
  33. data/lib/ruact/server_functions/backtrace_cleaner.rb +32 -0
  34. data/lib/ruact/server_functions/bucket_two_payload.rb +109 -0
  35. data/lib/ruact/server_functions/codegen.rb +344 -0
  36. data/lib/ruact/server_functions/codegen_v2.rb +212 -0
  37. data/lib/ruact/server_functions/endpoint_controller.rb +237 -0
  38. data/lib/ruact/server_functions/error_payload.rb +93 -0
  39. data/lib/ruact/server_functions/error_rendering.rb +190 -0
  40. data/lib/ruact/server_functions/error_suggestion.rb +38 -0
  41. data/lib/ruact/server_functions/name_bridge.rb +118 -0
  42. data/lib/ruact/server_functions/query_context.rb +62 -0
  43. data/lib/ruact/server_functions/query_dispatch.rb +313 -0
  44. data/lib/ruact/server_functions/query_source.rb +150 -0
  45. data/lib/ruact/server_functions/registry.rb +148 -0
  46. data/lib/ruact/server_functions/registry_entry.rb +26 -0
  47. data/lib/ruact/server_functions/route_source.rb +201 -0
  48. data/lib/ruact/server_functions/snapshot.rb +195 -0
  49. data/lib/ruact/server_functions/snapshot_writer.rb +65 -0
  50. data/lib/ruact/server_functions/standalone_context.rb +103 -0
  51. data/lib/ruact/server_functions/standalone_dispatcher.rb +178 -0
  52. data/lib/ruact/server_functions.rb +111 -0
  53. data/lib/ruact/version.rb +1 -1
  54. data/lib/ruact/view_helper.rb +17 -9
  55. data/lib/ruact.rb +85 -6
  56. data/lib/rubocop/cop/ruact/no_shared_state.rb +1 -1
  57. data/lib/tasks/benchmark.rake +15 -11
  58. data/lib/tasks/ruact.rake +81 -0
  59. data/spec/benchmarks/render_pipeline_benchmark_spec.rb +1 -1
  60. data/spec/fixtures/flight/README.md +55 -7
  61. data/spec/fixtures/flight/bigint.txt +1 -0
  62. data/spec/fixtures/flight/infinity.txt +1 -0
  63. data/spec/fixtures/flight/nan.txt +1 -0
  64. data/spec/fixtures/flight/negative_infinity.txt +1 -0
  65. data/spec/fixtures/flight/undefined.txt +1 -0
  66. data/spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb +3 -0
  67. data/spec/ruact/client_manifest_spec.rb +108 -0
  68. data/spec/ruact/configuration_spec.rb +501 -0
  69. data/spec/ruact/controller_request_spec.rb +204 -0
  70. data/spec/ruact/controller_spec.rb +427 -39
  71. data/spec/ruact/doctor_spec.rb +118 -0
  72. data/spec/ruact/erb_preprocessor_hook_spec.rb +3 -3
  73. data/spec/ruact/erb_preprocessor_spec.rb +7 -7
  74. data/spec/ruact/errors_spec.rb +95 -0
  75. data/spec/ruact/flight/renderer_spec.rb +14 -3
  76. data/spec/ruact/flight/serializer_spec.rb +129 -88
  77. data/spec/ruact/html_converter_spec.rb +183 -5
  78. data/spec/ruact/install_generator_spec.rb +93 -0
  79. data/spec/ruact/query_request_spec.rb +598 -0
  80. data/spec/ruact/query_spec.rb +105 -0
  81. data/spec/ruact/railtie_spec.rb +2 -3
  82. data/spec/ruact/render_context_spec.rb +58 -0
  83. data/spec/ruact/render_pipeline_concurrency_spec.rb +78 -0
  84. data/spec/ruact/render_pipeline_spec.rb +784 -330
  85. data/spec/ruact/serializable_spec.rb +8 -8
  86. data/spec/ruact/server_bucket_request_spec.rb +352 -0
  87. data/spec/ruact/server_function_name_spec.rb +53 -0
  88. data/spec/ruact/server_functions/backtrace_cleaner_spec.rb +63 -0
  89. data/spec/ruact/server_functions/bucket_two_payload_spec.rb +200 -0
  90. data/spec/ruact/server_functions/codegen_spec.rb +508 -0
  91. data/spec/ruact/server_functions/csrf_request_spec.rb +380 -0
  92. data/spec/ruact/server_functions/dispatch_request_spec.rb +819 -0
  93. data/spec/ruact/server_functions/error_payload_spec.rb +222 -0
  94. data/spec/ruact/server_functions/error_suggestion_spec.rb +79 -0
  95. data/spec/ruact/server_functions/name_bridge_spec.rb +212 -0
  96. data/spec/ruact/server_functions/query_context_spec.rb +72 -0
  97. data/spec/ruact/server_functions/query_source_spec.rb +142 -0
  98. data/spec/ruact/server_functions/railtie_integration_spec.rb +412 -0
  99. data/spec/ruact/server_functions/rake_spec.rb +86 -0
  100. data/spec/ruact/server_functions/registry_spec.rb +199 -0
  101. data/spec/ruact/server_functions/route_source_spec.rb +202 -0
  102. data/spec/ruact/server_functions/snapshot_spec.rb +256 -0
  103. data/spec/ruact/server_functions/snapshot_writer_spec.rb +71 -0
  104. data/spec/ruact/server_functions/standalone_action_spec.rb +224 -0
  105. data/spec/ruact/server_functions/standalone_context_spec.rb +142 -0
  106. data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +273 -0
  107. data/spec/ruact/server_rescue_request_spec.rb +416 -0
  108. data/spec/ruact/server_spec.rb +180 -0
  109. data/spec/ruact/server_upload_request_spec.rb +311 -0
  110. data/spec/ruact/view_helper_spec.rb +23 -17
  111. data/spec/spec_helper.rb +52 -1
  112. data/spec/support/fixtures/pixel.png +0 -0
  113. data/spec/support/flight_wire_parser.rb +135 -0
  114. data/spec/support/flight_wire_parser_spec.rb +93 -0
  115. data/spec/support/matchers/flight_fixture_matcher.rb +356 -0
  116. data/spec/support/matchers/flight_fixture_matcher_spec.rb +250 -0
  117. data/spec/support/rails_stub.rb +75 -5
  118. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +173 -0
  119. data/vendor/javascript/ruact-server-functions-runtime/index.js +614 -0
  120. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +827 -0
  121. data/vendor/javascript/ruact-server-functions-runtime/package.json +29 -0
  122. data/vendor/javascript/ruact-server-functions-runtime/usequery.test.mjs +181 -0
  123. data/vendor/javascript/vite-plugin-ruact/index.js +164 -0
  124. data/vendor/javascript/vite-plugin-ruact/package-lock.json +1429 -0
  125. data/vendor/javascript/vite-plugin-ruact/package.json +15 -0
  126. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +804 -0
  127. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +961 -0
  128. data/vendor/javascript/vite-plugin-ruact/vitest.config.mjs +21 -0
  129. metadata +91 -5
  130. data/lib/ruact/component_registry.rb +0 -31
  131. data/lib/tasks/rsc.rake +0 -9
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Story 9.4 — unit spec for the Ruact::Query base class. Deliberately NO Rails
4
+ # boot (AC3): a query subclass is exercised with a plain double standing in for
5
+ # the dispatch context, proving `CatalogQuery.new(fake_context).categories` is
6
+ # unit-testable in isolation.
7
+ require "spec_helper"
8
+
9
+ module Ruact
10
+ RSpec.describe Query, :story_9_4 do
11
+ let(:fake_user) { { "id" => 42 } }
12
+ let(:fake_params) { { "q" => "ruby" } }
13
+ let(:fake_request) { instance_double(Object) }
14
+ let(:fake_session) { { "token" => "abc" } }
15
+ let(:context) do
16
+ double(
17
+ current_user: fake_user,
18
+ params: fake_params,
19
+ request: fake_request,
20
+ session: fake_session
21
+ )
22
+ end
23
+
24
+ let(:query_class) do
25
+ Class.new(described_class) do
26
+ def categories
27
+ %w[books games]
28
+ end
29
+
30
+ def whoami
31
+ current_user
32
+ end
33
+
34
+ def search
35
+ params["q"]
36
+ end
37
+ end
38
+ end
39
+
40
+ describe "Story 9.4 — context accessors delegate to the injected context (AC3)" do
41
+ subject(:query) { query_class.new(context) }
42
+
43
+ it "exposes current_user from the context" do
44
+ expect(query.whoami).to eq(fake_user)
45
+ end
46
+
47
+ it "exposes params from the context" do
48
+ expect(query.search).to eq("ruby")
49
+ end
50
+
51
+ it "exposes request from the context" do
52
+ expect(query.request).to be(fake_request)
53
+ end
54
+
55
+ it "exposes session from the context" do
56
+ expect(query.session).to eq(fake_session)
57
+ end
58
+
59
+ it "is unit-testable with a plain fake context and no Rails boot (AC3)" do
60
+ expect(query_class.new(context).categories).to eq(%w[books games])
61
+ end
62
+ end
63
+
64
+ describe "Story 9.4 — accessor methods are inherited, never own methods of the subclass (AC1)" do
65
+ it "keeps current_user/params/request/session OUT of the subclass's public_instance_methods(false)" do
66
+ own = query_class.public_instance_methods(false)
67
+ expect(own).to contain_exactly(:categories, :whoami, :search)
68
+ end
69
+
70
+ it "defines the accessors on Ruact::Query itself (inherited by every subclass)" do
71
+ expect(described_class.public_instance_methods(false))
72
+ .to include(:current_user, :params, :request, :session)
73
+ end
74
+ end
75
+
76
+ describe "Story 9.4 — ruact_skip_before_action class macro (AC4 / D1)" do
77
+ it "records the callback with its options on the query class" do
78
+ klass = Class.new(described_class)
79
+ klass.ruact_skip_before_action(:require_login, only: :categories)
80
+ expect(klass.__ruact_skipped_callbacks).to eq([[[:require_login], { only: :categories }]])
81
+ end
82
+
83
+ it "accepts multiple callbacks in one call, mirroring Rails' skip_before_action" do
84
+ klass = Class.new(described_class)
85
+ klass.ruact_skip_before_action(:require_login, :check_tenant)
86
+ expect(klass.__ruact_skipped_callbacks).to eq([[%i[require_login check_tenant], {}]])
87
+ end
88
+
89
+ it "accumulates across calls in declaration order" do
90
+ klass = Class.new(described_class)
91
+ klass.ruact_skip_before_action(:require_login)
92
+ klass.ruact_skip_before_action(:check_tenant, raise: false)
93
+ expect(klass.__ruact_skipped_callbacks)
94
+ .to eq([[[:require_login], {}], [[:check_tenant], { raise: false }]])
95
+ end
96
+
97
+ it "keeps the recorded skips per-class — sibling query classes never share them" do
98
+ klass_a = Class.new(described_class)
99
+ klass_b = Class.new(described_class)
100
+ klass_a.ruact_skip_before_action(:require_login)
101
+ expect(klass_b.__ruact_skipped_callbacks).to be_empty
102
+ end
103
+ end
104
+ end
105
+ end
@@ -107,9 +107,8 @@ RSpec.describe Ruact::Railtie do
107
107
  end
108
108
 
109
109
  context "with missing manifest in production (AC#6)" do
110
- before do
111
- Rails.env = ActiveSupport::StringInquirer.new("production")
112
- end
110
+ before { Rails.env = ActiveSupport::StringInquirer.new("production") }
111
+ after { Rails.env = ActiveSupport::StringInquirer.new("development") }
113
112
 
114
113
  it "raises ManifestError" do
115
114
  expect { described_class.check_manifest!(missing_path) }
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ module Ruact
6
+ RSpec.describe RenderContext do
7
+ subject(:ctx) { described_class.new }
8
+
9
+ describe "#register" do
10
+ it "appends a new component entry" do
11
+ ctx.register("NavBar", { "currentUser" => 1 })
12
+ expect(ctx.components.length).to eq(1)
13
+ expect(ctx.components.first[:name]).to eq("NavBar")
14
+ expect(ctx.components.first[:props]).to eq({ "currentUser" => 1 })
15
+ end
16
+
17
+ it "returns a token of the form __RUACT_<index>__" do
18
+ token = ctx.register("Foo", {})
19
+ expect(token).to eq("__RUACT_0__")
20
+ end
21
+
22
+ it "increments token indices across successive registrations" do
23
+ t0 = ctx.register("A", {})
24
+ t1 = ctx.register("B", {})
25
+ t2 = ctx.register("C", {})
26
+ expect([t0, t1, t2]).to eq(%w[__RUACT_0__ __RUACT_1__ __RUACT_2__])
27
+ end
28
+ end
29
+
30
+ describe "#components" do
31
+ it "starts empty" do
32
+ expect(ctx.components).to eq([])
33
+ end
34
+ end
35
+
36
+ describe "#by_token" do
37
+ it "finds a registered component by its token" do
38
+ ctx.register("NavBar", { "x" => 1 })
39
+ entry = ctx.by_token("__RUACT_0__")
40
+ expect(entry[:name]).to eq("NavBar")
41
+ end
42
+
43
+ it "returns nil for an unknown token" do
44
+ expect(ctx.by_token("__RUACT_99__")).to be_nil
45
+ end
46
+ end
47
+
48
+ describe "isolation" do
49
+ it "two contexts are independent" do
50
+ a = described_class.new
51
+ b = described_class.new
52
+ a.register("A", {})
53
+ expect(b.components).to be_empty
54
+ expect(a.components.length).to eq(1)
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ module Ruact
6
+ RSpec.describe "Concurrent render isolation" do
7
+ let(:manifest) do
8
+ ClientManifest.from_hash(
9
+ (0..7).to_h do |i|
10
+ ["ThreadComponent#{i}", { "id" => "/tc#{i}.js",
11
+ "name" => "ThreadComponent#{i}",
12
+ "chunks" => ["/tc#{i}.js"] }]
13
+ end
14
+ )
15
+ end
16
+
17
+ let(:pipeline) { RenderPipeline.new(manifest) }
18
+
19
+ # Per Story 7.1 AC4: prove thread isolation deterministically with a
20
+ # countdown latch (Mutex+Queue) so all threads arrive at the render call
21
+ # simultaneously, fixed seed for reproducibility, ≥4 threads to outnumber
22
+ # typical CI cores, and ≥100 iterations to expose any race that fires
23
+ # ≥1% of the time.
24
+ let(:thread_count) { 4 }
25
+ let(:iterations) { 100 }
26
+
27
+ def run_isolation_iteration(iter, count, pipeline)
28
+ srand(0xC0DE + iter)
29
+ ready_latch = Queue.new
30
+ release = Queue.new
31
+ results = Array.new(count)
32
+ results_mu = Mutex.new
33
+
34
+ threads = Array.new(count) do |tid|
35
+ Thread.new do
36
+ ctx = Object.new
37
+ ctx.instance_variable_set(:@tid, tid)
38
+ binding_ctx = ctx.instance_eval { binding }
39
+ ready_latch << :ready
40
+ release.pop
41
+ output = pipeline.render(
42
+ { erb: "<ThreadComponent#{tid} thread_id={@tid} />", binding: binding_ctx },
43
+ mode: :string
44
+ )
45
+ results_mu.synchronize { results[tid] = output }
46
+ end
47
+ end
48
+
49
+ count.times { ready_latch.pop }
50
+ count.times { release << :go }
51
+ threads.each(&:join)
52
+ results
53
+ end
54
+
55
+ it "isolates each render's component registry from other concurrent renders" do
56
+ iterations.times do |iter|
57
+ results = run_isolation_iteration(iter, thread_count, pipeline)
58
+
59
+ results.each_with_index do |output, tid|
60
+ expect(output).to include_flight_row(
61
+ class: :import, payload: array_including("ThreadComponent#{tid}")
62
+ ), "iter #{iter} thread #{tid}: missing own component import"
63
+ expect(output).to include_flight_row(
64
+ class: :model, payload: array_including(hash_including("thread_id" => tid))
65
+ ), "iter #{iter} thread #{tid}: missing own thread_id prop"
66
+
67
+ (0...thread_count).each do |other_tid|
68
+ next if other_tid == tid
69
+
70
+ expect(output).not_to include_flight_row(
71
+ class: :import, payload: array_including("ThreadComponent#{other_tid}")
72
+ ), "iter #{iter} thread #{tid} leaked ThreadComponent#{other_tid}"
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end