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.
- checksums.yaml +4 -4
- data/.codecov.yml +31 -0
- data/.github/workflows/ci.yml +160 -94
- data/.github/workflows/server-functions-bench.yml +54 -0
- data/.rubocop.yml +19 -1
- data/.rubocop_todo.yml +175 -0
- data/CHANGELOG.md +88 -5
- data/README.md +2 -0
- data/RELEASING.md +9 -3
- data/bench/server_functions_dispatch_bench.rb +309 -0
- data/bench/server_functions_dispatch_bench.results.md +121 -0
- data/docs/internal/README.md +9 -0
- data/docs/internal/decisions/server-functions-api.md +1779 -0
- data/lib/generators/ruact/install/install_generator.rb +43 -0
- data/lib/generators/ruact/install/templates/application.jsx.tt +1 -1
- data/lib/generators/ruact/install/templates/initializer.rb.tt +1 -1
- data/lib/ruact/client_manifest.rb +125 -12
- data/lib/ruact/configuration.rb +264 -23
- data/lib/ruact/controller.rb +459 -32
- data/lib/ruact/doctor.rb +34 -2
- data/lib/ruact/erb_preprocessor.rb +6 -6
- data/lib/ruact/errors.rb +100 -0
- data/lib/ruact/flight/serializer.rb +2 -2
- data/lib/ruact/html_converter.rb +131 -31
- data/lib/ruact/query.rb +107 -0
- data/lib/ruact/railtie.rb +220 -3
- data/lib/ruact/render_context.rb +30 -0
- data/lib/ruact/render_pipeline.rb +201 -59
- data/lib/ruact/routing.rb +81 -0
- data/lib/ruact/serializable.rb +11 -11
- data/lib/ruact/server.rb +341 -0
- data/lib/ruact/server_action.rb +131 -0
- data/lib/ruact/server_functions/backtrace_cleaner.rb +32 -0
- data/lib/ruact/server_functions/bucket_two_payload.rb +109 -0
- data/lib/ruact/server_functions/codegen.rb +344 -0
- data/lib/ruact/server_functions/codegen_v2.rb +212 -0
- data/lib/ruact/server_functions/endpoint_controller.rb +237 -0
- data/lib/ruact/server_functions/error_payload.rb +93 -0
- data/lib/ruact/server_functions/error_rendering.rb +190 -0
- data/lib/ruact/server_functions/error_suggestion.rb +38 -0
- data/lib/ruact/server_functions/name_bridge.rb +118 -0
- data/lib/ruact/server_functions/query_context.rb +62 -0
- data/lib/ruact/server_functions/query_dispatch.rb +313 -0
- data/lib/ruact/server_functions/query_source.rb +150 -0
- data/lib/ruact/server_functions/registry.rb +148 -0
- data/lib/ruact/server_functions/registry_entry.rb +26 -0
- data/lib/ruact/server_functions/route_source.rb +201 -0
- data/lib/ruact/server_functions/snapshot.rb +195 -0
- data/lib/ruact/server_functions/snapshot_writer.rb +65 -0
- data/lib/ruact/server_functions/standalone_context.rb +103 -0
- data/lib/ruact/server_functions/standalone_dispatcher.rb +178 -0
- data/lib/ruact/server_functions.rb +111 -0
- data/lib/ruact/version.rb +1 -1
- data/lib/ruact/view_helper.rb +17 -9
- data/lib/ruact.rb +85 -6
- data/lib/rubocop/cop/ruact/no_shared_state.rb +1 -1
- data/lib/tasks/benchmark.rake +15 -11
- data/lib/tasks/ruact.rake +81 -0
- data/spec/benchmarks/render_pipeline_benchmark_spec.rb +1 -1
- data/spec/fixtures/flight/README.md +55 -7
- data/spec/fixtures/flight/bigint.txt +1 -0
- data/spec/fixtures/flight/infinity.txt +1 -0
- data/spec/fixtures/flight/nan.txt +1 -0
- data/spec/fixtures/flight/negative_infinity.txt +1 -0
- data/spec/fixtures/flight/undefined.txt +1 -0
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb +3 -0
- data/spec/ruact/client_manifest_spec.rb +108 -0
- data/spec/ruact/configuration_spec.rb +501 -0
- data/spec/ruact/controller_request_spec.rb +204 -0
- data/spec/ruact/controller_spec.rb +427 -39
- data/spec/ruact/doctor_spec.rb +118 -0
- data/spec/ruact/erb_preprocessor_hook_spec.rb +3 -3
- data/spec/ruact/erb_preprocessor_spec.rb +7 -7
- data/spec/ruact/errors_spec.rb +95 -0
- data/spec/ruact/flight/renderer_spec.rb +14 -3
- data/spec/ruact/flight/serializer_spec.rb +129 -88
- data/spec/ruact/html_converter_spec.rb +183 -5
- data/spec/ruact/install_generator_spec.rb +93 -0
- data/spec/ruact/query_request_spec.rb +598 -0
- data/spec/ruact/query_spec.rb +105 -0
- data/spec/ruact/railtie_spec.rb +2 -3
- data/spec/ruact/render_context_spec.rb +58 -0
- data/spec/ruact/render_pipeline_concurrency_spec.rb +78 -0
- data/spec/ruact/render_pipeline_spec.rb +784 -330
- data/spec/ruact/serializable_spec.rb +8 -8
- data/spec/ruact/server_bucket_request_spec.rb +352 -0
- data/spec/ruact/server_function_name_spec.rb +53 -0
- data/spec/ruact/server_functions/backtrace_cleaner_spec.rb +63 -0
- data/spec/ruact/server_functions/bucket_two_payload_spec.rb +200 -0
- data/spec/ruact/server_functions/codegen_spec.rb +508 -0
- data/spec/ruact/server_functions/csrf_request_spec.rb +380 -0
- data/spec/ruact/server_functions/dispatch_request_spec.rb +819 -0
- data/spec/ruact/server_functions/error_payload_spec.rb +222 -0
- data/spec/ruact/server_functions/error_suggestion_spec.rb +79 -0
- data/spec/ruact/server_functions/name_bridge_spec.rb +212 -0
- data/spec/ruact/server_functions/query_context_spec.rb +72 -0
- data/spec/ruact/server_functions/query_source_spec.rb +142 -0
- data/spec/ruact/server_functions/railtie_integration_spec.rb +412 -0
- data/spec/ruact/server_functions/rake_spec.rb +86 -0
- data/spec/ruact/server_functions/registry_spec.rb +199 -0
- data/spec/ruact/server_functions/route_source_spec.rb +202 -0
- data/spec/ruact/server_functions/snapshot_spec.rb +256 -0
- data/spec/ruact/server_functions/snapshot_writer_spec.rb +71 -0
- data/spec/ruact/server_functions/standalone_action_spec.rb +224 -0
- data/spec/ruact/server_functions/standalone_context_spec.rb +142 -0
- data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +273 -0
- data/spec/ruact/server_rescue_request_spec.rb +416 -0
- data/spec/ruact/server_spec.rb +180 -0
- data/spec/ruact/server_upload_request_spec.rb +311 -0
- data/spec/ruact/view_helper_spec.rb +23 -17
- data/spec/spec_helper.rb +52 -1
- data/spec/support/fixtures/pixel.png +0 -0
- data/spec/support/flight_wire_parser.rb +135 -0
- data/spec/support/flight_wire_parser_spec.rb +93 -0
- data/spec/support/matchers/flight_fixture_matcher.rb +356 -0
- data/spec/support/matchers/flight_fixture_matcher_spec.rb +250 -0
- data/spec/support/rails_stub.rb +75 -5
- data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +173 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.js +614 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +827 -0
- data/vendor/javascript/ruact-server-functions-runtime/package.json +29 -0
- data/vendor/javascript/ruact-server-functions-runtime/usequery.test.mjs +181 -0
- data/vendor/javascript/vite-plugin-ruact/index.js +164 -0
- data/vendor/javascript/vite-plugin-ruact/package-lock.json +1429 -0
- data/vendor/javascript/vite-plugin-ruact/package.json +15 -0
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +804 -0
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +961 -0
- data/vendor/javascript/vite-plugin-ruact/vitest.config.mjs +21 -0
- metadata +91 -5
- data/lib/ruact/component_registry.rb +0 -31
- 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
|
data/spec/ruact/railtie_spec.rb
CHANGED
|
@@ -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
|
|
111
|
-
|
|
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
|