ruact 0.0.10 → 0.0.12
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 +123 -48
- data/CONTRIBUTING.md +233 -0
- data/README.md +16 -6
- data/RELEASING.md +125 -139
- data/lib/ruact/packaging.rb +68 -0
- data/lib/ruact/version.rb +1 -1
- data/vendor/javascript/vite-plugin-ruact/flight-client.test.mjs +321 -0
- data/vendor/javascript/vite-plugin-ruact/package-lock.json +11 -0
- data/vendor/javascript/vite-plugin-ruact/package.json +1 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +14 -3
- metadata +6 -107
- data/.codecov.yml +0 -31
- data/.github/workflows/ci.yml +0 -284
- data/.github/workflows/server-functions-bench.yml +0 -54
- data/.rubocop.yml +0 -107
- data/.rubocop_todo.yml +0 -63
- data/Rakefile +0 -10
- data/bench/server_functions_dispatch_bench.rb +0 -276
- data/bench/server_functions_dispatch_bench.results.md +0 -150
- data/docs/internal/README.md +0 -9
- data/docs/internal/decisions/server-functions-api.md +0 -2236
- data/spec/benchmarks/baseline.json +0 -1
- data/spec/benchmarks/render_pipeline_benchmark_spec.rb +0 -92
- data/spec/fixtures/flight/README.md +0 -136
- data/spec/fixtures/flight/array.txt +0 -1
- data/spec/fixtures/flight/as_json_object.txt +0 -2
- data/spec/fixtures/flight/bigint.txt +0 -1
- data/spec/fixtures/flight/boolean_false.txt +0 -1
- data/spec/fixtures/flight/boolean_true.txt +0 -1
- data/spec/fixtures/flight/client_component_with_props.txt +0 -2
- data/spec/fixtures/flight/client_reference.txt +0 -2
- data/spec/fixtures/flight/hash.txt +0 -1
- data/spec/fixtures/flight/infinity.txt +0 -1
- data/spec/fixtures/flight/nan.txt +0 -1
- data/spec/fixtures/flight/negative_infinity.txt +0 -1
- data/spec/fixtures/flight/nil.txt +0 -1
- data/spec/fixtures/flight/number_float.txt +0 -1
- data/spec/fixtures/flight/number_integer.txt +0 -1
- data/spec/fixtures/flight/react_element_no_props.txt +0 -1
- data/spec/fixtures/flight/redirect_row.txt +0 -1
- data/spec/fixtures/flight/serializable_object.txt +0 -2
- data/spec/fixtures/flight/string_basic.txt +0 -1
- data/spec/fixtures/flight/string_dollar_escape.txt +0 -1
- data/spec/fixtures/flight/undefined.txt +0 -1
- data/spec/fixtures/readme/children-error.html.erb +0 -3
- data/spec/fixtures/readme/children-error.txt +0 -1
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb +0 -3
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/errors_demo/new.html.erb +0 -3
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/exploding_layout_demo/show.html.erb +0 -3
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/ghost_layout_demo/show.html.erb +0 -3
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/layout_demo/show.html.erb +0 -3
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/rootless_layout_demo/show.html.erb +0 -3
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/unwired_layout_demo/show.html.erb +0 -3
- data/spec/fixtures/story_7_9_views/layouts/bare_host.html.erb +0 -16
- data/spec/fixtures/story_7_9_views/layouts/exploding_host.html.erb +0 -24
- data/spec/fixtures/story_7_9_views/layouts/rootless_host.html.erb +0 -15
- data/spec/fixtures/story_7_9_views/layouts/ruact_host.html.erb +0 -17
- data/spec/readme_demo_message_spec.rb +0 -67
- data/spec/readme_spec.rb +0 -282
- data/spec/ruact/client_manifest_spec.rb +0 -270
- data/spec/ruact/component_contract_spec.rb +0 -119
- data/spec/ruact/configuration_spec.rb +0 -518
- data/spec/ruact/controller_request_spec.rb +0 -671
- data/spec/ruact/controller_spec.rb +0 -343
- data/spec/ruact/doctor_spec.rb +0 -769
- data/spec/ruact/erb_preprocessor_hook_spec.rb +0 -55
- data/spec/ruact/erb_preprocessor_spec.rb +0 -361
- data/spec/ruact/errors_spec.rb +0 -93
- data/spec/ruact/flight/renderer_spec.rb +0 -133
- data/spec/ruact/flight/serializer_spec.rb +0 -494
- data/spec/ruact/html_converter_spec.rb +0 -375
- data/spec/ruact/install_generator_spec.rb +0 -1549
- data/spec/ruact/layout_source_spec.rb +0 -108
- data/spec/ruact/manifest_resolver_spec.rb +0 -174
- data/spec/ruact/query_request_spec.rb +0 -706
- data/spec/ruact/query_spec.rb +0 -105
- data/spec/ruact/railtie_spec.rb +0 -155
- data/spec/ruact/render_context_spec.rb +0 -58
- data/spec/ruact/render_pipeline_concurrency_spec.rb +0 -78
- data/spec/ruact/render_pipeline_spec.rb +0 -928
- data/spec/ruact/scaffold_generator_spec.rb +0 -1849
- data/spec/ruact/serializable_spec.rb +0 -179
- data/spec/ruact/server_bucket_request_spec.rb +0 -785
- data/spec/ruact/server_function_name_spec.rb +0 -53
- data/spec/ruact/server_functions/backtrace_cleaner_spec.rb +0 -63
- data/spec/ruact/server_functions/bucket_two_payload_spec.rb +0 -200
- data/spec/ruact/server_functions/codegen_spec.rb +0 -397
- data/spec/ruact/server_functions/error_payload_spec.rb +0 -222
- data/spec/ruact/server_functions/error_suggestion_spec.rb +0 -79
- data/spec/ruact/server_functions/introspection_spec.rb +0 -135
- data/spec/ruact/server_functions/name_bridge_spec.rb +0 -212
- data/spec/ruact/server_functions/query_context_spec.rb +0 -72
- data/spec/ruact/server_functions/query_source_spec.rb +0 -193
- data/spec/ruact/server_functions/railtie_integration_spec.rb +0 -215
- data/spec/ruact/server_functions/rake_spec.rb +0 -86
- data/spec/ruact/server_functions/route_source_spec.rb +0 -202
- data/spec/ruact/server_functions/snapshot_spec.rb +0 -96
- data/spec/ruact/server_functions/snapshot_writer_spec.rb +0 -71
- data/spec/ruact/server_rescue_request_spec.rb +0 -416
- data/spec/ruact/server_spec.rb +0 -179
- data/spec/ruact/server_upload_request_spec.rb +0 -311
- data/spec/ruact/signed_references_spec.rb +0 -164
- data/spec/ruact/string_distance_spec.rb +0 -38
- data/spec/ruact/tasks_json_introspection_spec.rb +0 -141
- data/spec/ruact/testing/have_ruact_component_spec.rb +0 -170
- data/spec/ruact/testing/no_production_load_spec.rb +0 -41
- data/spec/ruact/validation_errors_spec.rb +0 -116
- data/spec/ruact/view_helper_spec.rb +0 -131
- data/spec/spec_helper.rb +0 -77
- data/spec/support/fixtures/pixel.png +0 -0
- data/spec/support/flight_wire_parser.rb +0 -21
- data/spec/support/flight_wire_parser_spec.rb +0 -93
- data/spec/support/matchers/flight_fixture_matcher.rb +0 -130
- data/spec/support/matchers/flight_fixture_matcher_spec.rb +0 -250
- data/spec/support/rails_stub.rb +0 -115
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ruact
|
|
4
|
+
# What goes inside the published `.gem`, as a predicate rather than a list.
|
|
5
|
+
#
|
|
6
|
+
# There is exactly one writer of this rule. `ruact.gemspec` calls it to build
|
|
7
|
+
# `spec.files`, and `bin/release-gate` calls it to decide whether a pull
|
|
8
|
+
# request changed the bytes a consumer installs. A hand-kept path list on
|
|
9
|
+
# either side would be a second copy of the packaging rule, and it would drift
|
|
10
|
+
# from this one the first time the rule changed — which is the whole reason
|
|
11
|
+
# the gate does not carry its own.
|
|
12
|
+
#
|
|
13
|
+
# This file is **not** required from `lib/ruact.rb`. It is build-time code
|
|
14
|
+
# that happens to live under `lib/` so the gemspec can `require_relative` into
|
|
15
|
+
# it; nothing at runtime loads it, and nothing at runtime may come to depend
|
|
16
|
+
# on it.
|
|
17
|
+
#
|
|
18
|
+
# WHAT IT DOES NOT PROMISE
|
|
19
|
+
#
|
|
20
|
+
# That the path exists, is tracked, or is readable. It answers a question
|
|
21
|
+
# about a path's shape and nothing else; both callers hand it paths that
|
|
22
|
+
# `git` has already vouched for.
|
|
23
|
+
module Packaging
|
|
24
|
+
# Directory trees a consumer of the installed gem actually reaches:
|
|
25
|
+
# `lib/` is the library, `sig/` is its RBS surface, and
|
|
26
|
+
# `vendor/javascript/` is public surface too — the bundled Vite plugin and
|
|
27
|
+
# browser runtime are resolved by filesystem path off the installed gem, so
|
|
28
|
+
# a release that left them out would generate imports to files that do not
|
|
29
|
+
# exist on the consumer's disk.
|
|
30
|
+
TREES = %w[lib/ sig/ vendor/javascript/].freeze
|
|
31
|
+
|
|
32
|
+
# `vendor/bundle/` is the local Bundler install directory. It is not under
|
|
33
|
+
# any of {TREES} today; the exclusion is here so that widening a tree above
|
|
34
|
+
# cannot quietly start packaging it.
|
|
35
|
+
EXCLUDED_TREES = %w[vendor/bundle/].freeze
|
|
36
|
+
|
|
37
|
+
# The documents that ship beside the code, expressed as a shape rather than
|
|
38
|
+
# a roster: a tracked file at the top level whose extension is `.md` or
|
|
39
|
+
# `.txt`. That covers the readme, the changelog, the licence, the security
|
|
40
|
+
# policy and both guides, and it keeps covering the next one without anyone
|
|
41
|
+
# remembering to add it here.
|
|
42
|
+
#
|
|
43
|
+
# It also decides the near misses by construction. `ruact.gemspec`,
|
|
44
|
+
# `Gemfile`, `Gemfile.lock`, `Rakefile`, `.rubocop.yml` and `.gitignore` are
|
|
45
|
+
# all top-level and none of them is a document, so none of them ships.
|
|
46
|
+
ROOT_DOCUMENT = %r{\A[^/]+\.(?:md|txt)\z}i
|
|
47
|
+
|
|
48
|
+
# Does this repository-relative path go inside the published gem?
|
|
49
|
+
#
|
|
50
|
+
# @param path [String] a repository-relative path, as `git ls-files` and
|
|
51
|
+
# `git diff --name-only` both emit it (forward slashes, no leading `./`)
|
|
52
|
+
# @return [Boolean] true when the path is part of what a consumer installs
|
|
53
|
+
def self.packaged?(path)
|
|
54
|
+
return false if EXCLUDED_TREES.any? { |tree| path.start_with?(tree) }
|
|
55
|
+
|
|
56
|
+
TREES.any? { |tree| path.start_with?(tree) } || path.match?(ROOT_DOCUMENT)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The packaged subset of a list of repository-relative paths, order
|
|
60
|
+
# preserved.
|
|
61
|
+
#
|
|
62
|
+
# @param paths [Enumerable<String>] repository-relative paths
|
|
63
|
+
# @return [Array<String>] those of them that go inside the published gem
|
|
64
|
+
def self.packaged_paths(paths)
|
|
65
|
+
paths.select { |path| packaged?(path) }
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
data/lib/ruact/version.rb
CHANGED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
// Story 17.0a (issue #62) — the Flight client must not collapse arrays.
|
|
2
|
+
//
|
|
3
|
+
// `flight-client.js` was the only runtime module in this package without a unit
|
|
4
|
+
// test, and the defect this file pins is what that gap allowed: `_buildTree`
|
|
5
|
+
// applied a single-element unwrap (`items.length === 1 ? items[0] : items`) to
|
|
6
|
+
// EVERY array it walked. `_buildTree` is a generic value walker with no
|
|
7
|
+
// positional information, so it could not tell a `children` array from a data
|
|
8
|
+
// array sitting in a prop — a list prop holding exactly one row arrived at the
|
|
9
|
+
// client component as the row itself.
|
|
10
|
+
//
|
|
11
|
+
// What this file covers:
|
|
12
|
+
//
|
|
13
|
+
// 1. Data props — 0 / 1 / 2 elements, at top level and nested, arrive with
|
|
14
|
+
// their arity intact. The n=1 cases are the regression; n=0 and n=2 are
|
|
15
|
+
// guards that the fix does not over-correct in the other direction.
|
|
16
|
+
// 2. Children — 0 / 1 / 2, asserting the REBUILT content (type, key, nested
|
|
17
|
+
// children), not just arity, so a rebuild returning nulls cannot pass.
|
|
18
|
+
// Children keep their arity too: the server collapses a lone TAG-NESTED
|
|
19
|
+
// child before the wire (`html_converter.rb:188`, Suspense at `:202`), but
|
|
20
|
+
// an explicit `children={[...]}` prop reaches the client as an array and
|
|
21
|
+
// now stays one. See the contract note on that describe block.
|
|
22
|
+
// 3. The gem-produced fixture — the same assertion driven by wire bytes the
|
|
23
|
+
// Ruby serializer wrote, not by a literal typed here.
|
|
24
|
+
//
|
|
25
|
+
// Isolation: `flight-client.js` keeps `pendingChunks` and `lazyCache` as
|
|
26
|
+
// module-level Maps that survive between tests in one process. `lazyCache` is
|
|
27
|
+
// memoized by rowId, so two cases reusing an id would leak into each other.
|
|
28
|
+
// `clearPendingChunks()` in `beforeEach` is what keeps this file order-
|
|
29
|
+
// independent. Vitest is not configured to shuffle, so nothing here depends on
|
|
30
|
+
// that guard today — it is insurance against a case that reuses a row id, and
|
|
31
|
+
// against shuffling being turned on later.
|
|
32
|
+
|
|
33
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
34
|
+
import fs from "node:fs";
|
|
35
|
+
import path from "node:path";
|
|
36
|
+
import {
|
|
37
|
+
buildTree,
|
|
38
|
+
createFromFlightPayload,
|
|
39
|
+
clearPendingChunks,
|
|
40
|
+
} from "./runtime/flight-client.js";
|
|
41
|
+
|
|
42
|
+
// A stand-in client component. Never rendered — these tests assert the props
|
|
43
|
+
// the client BUILDS, which is the contract the component then consumes.
|
|
44
|
+
const TaskList = () => null;
|
|
45
|
+
const PostList = () => null; // the fixture's component — see the last describe
|
|
46
|
+
const MODULE_REGISTRY = {
|
|
47
|
+
"/TaskList.jsx": { TaskList },
|
|
48
|
+
"/PostList.jsx": { PostList },
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const EMPTY_ROWS = new Map();
|
|
52
|
+
|
|
53
|
+
/** Wire payload: one import row + a root element carrying `props`. */
|
|
54
|
+
function payloadWithProps(props) {
|
|
55
|
+
return [
|
|
56
|
+
'1:I["/TaskList.jsx","TaskList"]',
|
|
57
|
+
`0:["$","$L1",null,${JSON.stringify(props)}]`,
|
|
58
|
+
"",
|
|
59
|
+
].join("\n");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
beforeEach(() => {
|
|
63
|
+
clearPendingChunks();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("Story 17.0a — data props keep their arity", () => {
|
|
67
|
+
it("a prop holding ONE item arrives as a one-element array", () => {
|
|
68
|
+
const tree = createFromFlightPayload(
|
|
69
|
+
payloadWithProps({ tasks: [{ id: 6, title: "Buy coffee" }] }),
|
|
70
|
+
MODULE_REGISTRY,
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
expect(Array.isArray(tree.props.tasks)).toBe(true);
|
|
74
|
+
expect(tree.props.tasks).toEqual([{ id: 6, title: "Buy coffee" }]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("a prop holding TWO items is unchanged", () => {
|
|
78
|
+
const tree = createFromFlightPayload(
|
|
79
|
+
payloadWithProps({ tasks: [{ id: 6 }, { id: 7 }] }),
|
|
80
|
+
MODULE_REGISTRY,
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
expect(tree.props.tasks).toEqual([{ id: 6 }, { id: 7 }]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("a prop holding an EMPTY array stays an empty array", () => {
|
|
87
|
+
const tree = createFromFlightPayload(
|
|
88
|
+
payloadWithProps({ tasks: [] }),
|
|
89
|
+
MODULE_REGISTRY,
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
expect(Array.isArray(tree.props.tasks)).toBe(true);
|
|
93
|
+
expect(tree.props.tasks).toEqual([]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("a one-element array NESTED inside an object prop keeps its arity", () => {
|
|
97
|
+
const tree = createFromFlightPayload(
|
|
98
|
+
payloadWithProps({ page: { rows: [{ id: 6 }], total: 1 } }),
|
|
99
|
+
MODULE_REGISTRY,
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
expect(Array.isArray(tree.props.page.rows)).toBe(true);
|
|
103
|
+
expect(tree.props.page.rows).toEqual([{ id: 6 }]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("a one-element array of PRIMITIVES keeps its arity", () => {
|
|
107
|
+
const tree = createFromFlightPayload(
|
|
108
|
+
payloadWithProps({ tags: ["$$ruby"] }),
|
|
109
|
+
MODULE_REGISTRY,
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
// Decoded, not merely carried: "$$ruby" on the wire is "$ruby" in the prop.
|
|
113
|
+
expect(tree.props.tags).toEqual(["$ruby"]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("the scaffold's own prop shape survives (issue #62 reproduction)", () => {
|
|
117
|
+
// `<PostList posts={rows} />` with exactly one record. The generated
|
|
118
|
+
// scaffold gates its table on `sortedRows.length > 0`, so an object here
|
|
119
|
+
// renders neither the table nor the empty state — a blank list, no error.
|
|
120
|
+
// The throw needs a specific sequence, because the sort controls live
|
|
121
|
+
// inside the table that did not render: search (which works, since results
|
|
122
|
+
// arrive as JSON), sort, then clear the search — that puts the object back
|
|
123
|
+
// through `[...rows]`.
|
|
124
|
+
const tree = createFromFlightPayload(
|
|
125
|
+
payloadWithProps({ posts: [{ id: 1, title: "First post" }] }),
|
|
126
|
+
MODULE_REGISTRY,
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
// The component itself, not just its props — a rebuild that resolved the
|
|
130
|
+
// `$L` reference to a plain "div" would keep every prop assertion green
|
|
131
|
+
// while never running PostList at all.
|
|
132
|
+
expect(tree.type).toBe(TaskList);
|
|
133
|
+
|
|
134
|
+
const { posts } = tree.props;
|
|
135
|
+
expect(Array.isArray(posts)).toBe(true);
|
|
136
|
+
expect(posts).toHaveLength(1);
|
|
137
|
+
expect(() => [...posts]).not.toThrow();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("an array INSIDE an array keeps both dimensions", () => {
|
|
141
|
+
// `.flat()` in the walker would collapse this and still satisfy every
|
|
142
|
+
// one-dimensional assertion above.
|
|
143
|
+
const tree = createFromFlightPayload(
|
|
144
|
+
payloadWithProps({ grid: [[1, 2], [3]] }),
|
|
145
|
+
MODULE_REGISTRY,
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
expect(tree.props.grid).toEqual([[1, 2], [3]]);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("a ONE-element outer array wrapping an array keeps both levels", () => {
|
|
152
|
+
// Distinct from the grid case above, whose outer array has two members: a
|
|
153
|
+
// collapse conditioned on `length === 1 && Array.isArray(items[0])` would
|
|
154
|
+
// pass that one and corrupt this one.
|
|
155
|
+
const tree = createFromFlightPayload(
|
|
156
|
+
payloadWithProps({ matrix: [[1, 2]] }),
|
|
157
|
+
MODULE_REGISTRY,
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
expect(tree.props.matrix).toEqual([[1, 2]]);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("an `undefined` MEMBER is kept — Flight distinguishes it from absence", () => {
|
|
164
|
+
// `$undefined` is a wire sentinel (project-context §7), so `[undefined]` is
|
|
165
|
+
// a one-member array, not an empty one. A `.filter(v => v !== undefined)`
|
|
166
|
+
// in the walker would silently make it empty.
|
|
167
|
+
const tree = createFromFlightPayload(
|
|
168
|
+
payloadWithProps({ slots: ["$undefined"] }),
|
|
169
|
+
MODULE_REGISTRY,
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
expect(tree.props.slots).toHaveLength(1);
|
|
173
|
+
expect(tree.props.slots[0]).toBeUndefined();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("falsy items survive — they are values, not absences", () => {
|
|
177
|
+
// `.filter(Boolean)` in the walker would drop these and still satisfy
|
|
178
|
+
// every length assertion that uses truthy items.
|
|
179
|
+
const tree = createFromFlightPayload(
|
|
180
|
+
payloadWithProps({ flags: [false, 0, null, "", 1] }),
|
|
181
|
+
MODULE_REGISTRY,
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
expect(tree.props.flags).toEqual([false, 0, null, "", 1]);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe("Story 17.0a — children keep their arity too (contract, decided 2026-09-09)", () => {
|
|
189
|
+
// Decision: arity is preserved for `children` as well as for data props.
|
|
190
|
+
//
|
|
191
|
+
// The server collapses a lone child produced by TAG NESTING before the wire
|
|
192
|
+
// (`html_converter.rb:188`, Suspense at `:202`), so that path is unaffected.
|
|
193
|
+
// But `children` passed as an EXPLICIT PROP on a self-closing tag —
|
|
194
|
+
// `<Label children={["hi"]} />` — reaches the wire as an array, and Story
|
|
195
|
+
// 15.2's loud error does not cover it (`erb_preprocessor.rb:174` exempts
|
|
196
|
+
// self-closing tags). Before this story the client collapsed that to `"hi"`.
|
|
197
|
+
// It no longer does: you passed an array, you get an array.
|
|
198
|
+
//
|
|
199
|
+
// Taken deliberately while the library has no external adopters — the only
|
|
200
|
+
// moment where making the contract consistent costs nothing.
|
|
201
|
+
|
|
202
|
+
it("a single child NESTED IN A TAG arrives collapsed, because the server collapsed it", () => {
|
|
203
|
+
const tree = buildTree(
|
|
204
|
+
["$", "div", null, { children: ["$", "em", null, { children: "hi" }] }],
|
|
205
|
+
EMPTY_ROWS,
|
|
206
|
+
MODULE_REGISTRY,
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
// Not an array: the server sent the element itself, and it rebuilds as one.
|
|
210
|
+
expect(Array.isArray(tree.props.children)).toBe(false);
|
|
211
|
+
expect(tree.props.children.type).toBe("em");
|
|
212
|
+
expect(tree.props.children.props.children).toBe("hi");
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("a single TEXT child nested in a tag arrives as the string", () => {
|
|
216
|
+
const tree = buildTree(
|
|
217
|
+
["$", "div", null, { children: "hi" }],
|
|
218
|
+
EMPTY_ROWS,
|
|
219
|
+
MODULE_REGISTRY,
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
expect(tree.props.children).toBe("hi");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("multiple children arrive as an array, each one rebuilt", () => {
|
|
226
|
+
const tree = buildTree(
|
|
227
|
+
["$", "ul", null, {
|
|
228
|
+
children: [
|
|
229
|
+
["$", "li", "a", { children: "one" }],
|
|
230
|
+
["$", "li", "b", { children: "two" }],
|
|
231
|
+
],
|
|
232
|
+
}],
|
|
233
|
+
EMPTY_ROWS,
|
|
234
|
+
MODULE_REGISTRY,
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
const kids = tree.props.children;
|
|
238
|
+
expect(Array.isArray(kids)).toBe(true);
|
|
239
|
+
expect(kids).toHaveLength(2);
|
|
240
|
+
// Content, not just arity — a rebuild that returned nulls would pass a
|
|
241
|
+
// length check and fail this one.
|
|
242
|
+
expect(kids.map((k) => k.type)).toEqual(["li", "li"]);
|
|
243
|
+
expect(kids.map((k) => k.key)).toEqual(["a", "b"]);
|
|
244
|
+
expect(kids.map((k) => k.props.children)).toEqual(["one", "two"]);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("an EXPLICIT one-element children prop stays a one-element array", () => {
|
|
248
|
+
// The contract change. `<Label children={["hi"]} />` — verified against the
|
|
249
|
+
// Ruby pipeline to reach the wire as `{"children":["hi"]}`.
|
|
250
|
+
const tree = buildTree(
|
|
251
|
+
["$", "span", null, { children: ["hi"] }],
|
|
252
|
+
EMPTY_ROWS,
|
|
253
|
+
MODULE_REGISTRY,
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
expect(tree.props.children).toEqual(["hi"]);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("a one-element children array of ELEMENTS stays an array, rebuilt", () => {
|
|
260
|
+
const tree = buildTree(
|
|
261
|
+
["$", "ul", null, { children: [["$", "li", "only", { children: "just one" }]] }],
|
|
262
|
+
EMPTY_ROWS,
|
|
263
|
+
MODULE_REGISTRY,
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
const kids = tree.props.children;
|
|
267
|
+
expect(Array.isArray(kids)).toBe(true);
|
|
268
|
+
expect(kids).toHaveLength(1);
|
|
269
|
+
expect(kids[0].type).toBe("li");
|
|
270
|
+
expect(kids[0].key).toBe("only");
|
|
271
|
+
expect(kids[0].props.children).toBe("just one");
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("an EMPTY children array stays an empty array", () => {
|
|
275
|
+
// AC3's n=0 for children. Distinct from "no children prop at all" below:
|
|
276
|
+
// this one is present and empty, and must not become visible content.
|
|
277
|
+
const tree = buildTree(
|
|
278
|
+
["$", "ul", null, { children: [] }],
|
|
279
|
+
EMPTY_ROWS,
|
|
280
|
+
MODULE_REGISTRY,
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
expect(Array.isArray(tree.props.children)).toBe(true);
|
|
284
|
+
expect(tree.props.children).toHaveLength(0);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("an element with no children prop has none after rebuilding", () => {
|
|
288
|
+
const tree = buildTree(["$", "br", null, {}], EMPTY_ROWS, MODULE_REGISTRY);
|
|
289
|
+
|
|
290
|
+
expect("children" in tree.props).toBe(false);
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
describe("Story 17.0a — the contract, driven by a gem-produced fixture", () => {
|
|
295
|
+
// The bytes were written by the Ruby serializer, not typed here.
|
|
296
|
+
//
|
|
297
|
+
// The two sides guarantee DIFFERENT things, and neither reddens for the
|
|
298
|
+
// other: `spec/ruact/flight/serializer_spec.rb` ("single-element array prop
|
|
299
|
+
// survives the wire (Story 17.0a)") asserts that what the serializer emits
|
|
300
|
+
// still equals this file, so a server-side change reddens THERE; this test
|
|
301
|
+
// asserts that these bytes rebuild into a one-element array, so a client-side
|
|
302
|
+
// change reddens HERE. What the pair buys is that the client is exercised
|
|
303
|
+
// against real server output rather than a literal someone typed — the
|
|
304
|
+
// Story 5.2 pattern. It does not buy simultaneous failure.
|
|
305
|
+
const FIXTURE = path.join(
|
|
306
|
+
import.meta.dirname,
|
|
307
|
+
"../../../spec/fixtures/flight/single_element_array_prop.txt",
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
it("the fixture the gem wrote rebuilds as a one-element array", () => {
|
|
311
|
+
const payload = fs.readFileSync(FIXTURE, "utf8");
|
|
312
|
+
const tree = createFromFlightPayload(payload, MODULE_REGISTRY);
|
|
313
|
+
|
|
314
|
+
expect(tree.type).toBe(PostList);
|
|
315
|
+
expect(Array.isArray(tree.props.posts)).toBe(true);
|
|
316
|
+
// The title is "$$5 plan" ON THE WIRE (§7 prepends one "$"). Asserting the
|
|
317
|
+
// DECODED value means a walker that preserved arity but stopped rebuilding
|
|
318
|
+
// scalar members would fail here rather than pass.
|
|
319
|
+
expect(tree.props.posts).toEqual([{ id: 1, title: "$5 plan" }]);
|
|
320
|
+
});
|
|
321
|
+
});
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
"version": "0.0.0-bundled",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"devDependencies": {
|
|
12
|
+
"react": "^19.2.8",
|
|
12
13
|
"typescript": "^5.5.0",
|
|
13
14
|
"vitest": "^2.0.0"
|
|
14
15
|
}
|
|
@@ -1133,6 +1134,16 @@
|
|
|
1133
1134
|
"node": "^10 || ^12 || >=14"
|
|
1134
1135
|
}
|
|
1135
1136
|
},
|
|
1137
|
+
"node_modules/react": {
|
|
1138
|
+
"version": "19.2.8",
|
|
1139
|
+
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
|
|
1140
|
+
"integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
|
|
1141
|
+
"dev": true,
|
|
1142
|
+
"license": "MIT",
|
|
1143
|
+
"engines": {
|
|
1144
|
+
"node": ">=0.10.0"
|
|
1145
|
+
}
|
|
1146
|
+
},
|
|
1136
1147
|
"node_modules/rollup": {
|
|
1137
1148
|
"version": "4.60.3",
|
|
1138
1149
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz",
|
|
@@ -196,9 +196,20 @@ function _buildTree(value, rows, moduleRegistry) {
|
|
|
196
196
|
return createElement(type, props);
|
|
197
197
|
}
|
|
198
198
|
|
|
199
|
-
// Plain array
|
|
200
|
-
|
|
201
|
-
|
|
199
|
+
// Plain array — fragment children OR a data array sitting in a prop.
|
|
200
|
+
//
|
|
201
|
+
// Arity is preserved. This walker has no positional information: it cannot
|
|
202
|
+
// tell a `children` array from a list prop, so any shape change made here
|
|
203
|
+
// is made to both. It used to collapse single-element arrays (`items[0]`
|
|
204
|
+
// when `length === 1`), which silently turned a one-row list prop into the
|
|
205
|
+
// row itself — issue #62.
|
|
206
|
+
//
|
|
207
|
+
// Nothing needs to compensate: the server already collapses a lone child
|
|
208
|
+
// before it reaches the wire (`html_converter.rb:188`, and the Suspense
|
|
209
|
+
// path at `:202`), and React renders an array of children fine. If a
|
|
210
|
+
// client-side collapse is ever needed, it belongs in `buildProps` under
|
|
211
|
+
// `key === "children"`, where the position IS known.
|
|
212
|
+
return value.map((v) => _buildTree(v, rows, moduleRegistry));
|
|
202
213
|
}
|
|
203
214
|
|
|
204
215
|
// --- Plain objects ---
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ruact
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.0.
|
|
4
|
+
version: 0.0.12
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Luiz Garcia
|
|
8
8
|
autorequire:
|
|
9
|
-
bindir:
|
|
9
|
+
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-09-09 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: nokogiri
|
|
@@ -36,21 +36,12 @@ executables: []
|
|
|
36
36
|
extensions: []
|
|
37
37
|
extra_rdoc_files: []
|
|
38
38
|
files:
|
|
39
|
-
- ".codecov.yml"
|
|
40
|
-
- ".github/workflows/ci.yml"
|
|
41
|
-
- ".github/workflows/server-functions-bench.yml"
|
|
42
|
-
- ".rubocop.yml"
|
|
43
|
-
- ".rubocop_todo.yml"
|
|
44
39
|
- CHANGELOG.md
|
|
40
|
+
- CONTRIBUTING.md
|
|
45
41
|
- LICENSE.txt
|
|
46
42
|
- README.md
|
|
47
43
|
- RELEASING.md
|
|
48
|
-
- Rakefile
|
|
49
44
|
- SECURITY.md
|
|
50
|
-
- bench/server_functions_dispatch_bench.rb
|
|
51
|
-
- bench/server_functions_dispatch_bench.results.md
|
|
52
|
-
- docs/internal/README.md
|
|
53
|
-
- docs/internal/decisions/server-functions-api.md
|
|
54
45
|
- lib/generators/ruact/install/install_generator.rb
|
|
55
46
|
- lib/generators/ruact/install/templates/AGENTS.md.tt
|
|
56
47
|
- lib/generators/ruact/install/templates/Procfile.dev.tt
|
|
@@ -97,6 +88,7 @@ files:
|
|
|
97
88
|
- lib/ruact/html_converter.rb
|
|
98
89
|
- lib/ruact/layout_source.rb
|
|
99
90
|
- lib/ruact/manifest_resolver.rb
|
|
91
|
+
- lib/ruact/packaging.rb
|
|
100
92
|
- lib/ruact/query.rb
|
|
101
93
|
- lib/ruact/railtie.rb
|
|
102
94
|
- lib/ruact/render_context.rb
|
|
@@ -139,106 +131,13 @@ files:
|
|
|
139
131
|
- lib/tasks/benchmark.rake
|
|
140
132
|
- lib/tasks/ruact.rake
|
|
141
133
|
- sig/ruact.rbs
|
|
142
|
-
- spec/benchmarks/baseline.json
|
|
143
|
-
- spec/benchmarks/render_pipeline_benchmark_spec.rb
|
|
144
|
-
- spec/fixtures/flight/README.md
|
|
145
|
-
- spec/fixtures/flight/array.txt
|
|
146
|
-
- spec/fixtures/flight/as_json_object.txt
|
|
147
|
-
- spec/fixtures/flight/bigint.txt
|
|
148
|
-
- spec/fixtures/flight/boolean_false.txt
|
|
149
|
-
- spec/fixtures/flight/boolean_true.txt
|
|
150
|
-
- spec/fixtures/flight/client_component_with_props.txt
|
|
151
|
-
- spec/fixtures/flight/client_reference.txt
|
|
152
|
-
- spec/fixtures/flight/hash.txt
|
|
153
|
-
- spec/fixtures/flight/infinity.txt
|
|
154
|
-
- spec/fixtures/flight/nan.txt
|
|
155
|
-
- spec/fixtures/flight/negative_infinity.txt
|
|
156
|
-
- spec/fixtures/flight/nil.txt
|
|
157
|
-
- spec/fixtures/flight/number_float.txt
|
|
158
|
-
- spec/fixtures/flight/number_integer.txt
|
|
159
|
-
- spec/fixtures/flight/react_element_no_props.txt
|
|
160
|
-
- spec/fixtures/flight/redirect_row.txt
|
|
161
|
-
- spec/fixtures/flight/serializable_object.txt
|
|
162
|
-
- spec/fixtures/flight/string_basic.txt
|
|
163
|
-
- spec/fixtures/flight/string_dollar_escape.txt
|
|
164
|
-
- spec/fixtures/flight/undefined.txt
|
|
165
|
-
- spec/fixtures/readme/children-error.html.erb
|
|
166
|
-
- spec/fixtures/readme/children-error.txt
|
|
167
|
-
- spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb
|
|
168
|
-
- spec/fixtures/story_7_9_views/controller_request_spec_support/errors_demo/new.html.erb
|
|
169
|
-
- spec/fixtures/story_7_9_views/controller_request_spec_support/exploding_layout_demo/show.html.erb
|
|
170
|
-
- spec/fixtures/story_7_9_views/controller_request_spec_support/ghost_layout_demo/show.html.erb
|
|
171
|
-
- spec/fixtures/story_7_9_views/controller_request_spec_support/layout_demo/show.html.erb
|
|
172
|
-
- spec/fixtures/story_7_9_views/controller_request_spec_support/rootless_layout_demo/show.html.erb
|
|
173
|
-
- spec/fixtures/story_7_9_views/controller_request_spec_support/unwired_layout_demo/show.html.erb
|
|
174
|
-
- spec/fixtures/story_7_9_views/layouts/bare_host.html.erb
|
|
175
|
-
- spec/fixtures/story_7_9_views/layouts/exploding_host.html.erb
|
|
176
|
-
- spec/fixtures/story_7_9_views/layouts/rootless_host.html.erb
|
|
177
|
-
- spec/fixtures/story_7_9_views/layouts/ruact_host.html.erb
|
|
178
|
-
- spec/readme_demo_message_spec.rb
|
|
179
|
-
- spec/readme_spec.rb
|
|
180
|
-
- spec/ruact/client_manifest_spec.rb
|
|
181
|
-
- spec/ruact/component_contract_spec.rb
|
|
182
|
-
- spec/ruact/configuration_spec.rb
|
|
183
|
-
- spec/ruact/controller_request_spec.rb
|
|
184
|
-
- spec/ruact/controller_spec.rb
|
|
185
|
-
- spec/ruact/doctor_spec.rb
|
|
186
|
-
- spec/ruact/erb_preprocessor_hook_spec.rb
|
|
187
|
-
- spec/ruact/erb_preprocessor_spec.rb
|
|
188
|
-
- spec/ruact/errors_spec.rb
|
|
189
|
-
- spec/ruact/flight/renderer_spec.rb
|
|
190
|
-
- spec/ruact/flight/serializer_spec.rb
|
|
191
|
-
- spec/ruact/html_converter_spec.rb
|
|
192
|
-
- spec/ruact/install_generator_spec.rb
|
|
193
|
-
- spec/ruact/layout_source_spec.rb
|
|
194
|
-
- spec/ruact/manifest_resolver_spec.rb
|
|
195
|
-
- spec/ruact/query_request_spec.rb
|
|
196
|
-
- spec/ruact/query_spec.rb
|
|
197
|
-
- spec/ruact/railtie_spec.rb
|
|
198
|
-
- spec/ruact/render_context_spec.rb
|
|
199
|
-
- spec/ruact/render_pipeline_concurrency_spec.rb
|
|
200
|
-
- spec/ruact/render_pipeline_spec.rb
|
|
201
|
-
- spec/ruact/scaffold_generator_spec.rb
|
|
202
|
-
- spec/ruact/serializable_spec.rb
|
|
203
|
-
- spec/ruact/server_bucket_request_spec.rb
|
|
204
|
-
- spec/ruact/server_function_name_spec.rb
|
|
205
|
-
- spec/ruact/server_functions/backtrace_cleaner_spec.rb
|
|
206
|
-
- spec/ruact/server_functions/bucket_two_payload_spec.rb
|
|
207
|
-
- spec/ruact/server_functions/codegen_spec.rb
|
|
208
|
-
- spec/ruact/server_functions/error_payload_spec.rb
|
|
209
|
-
- spec/ruact/server_functions/error_suggestion_spec.rb
|
|
210
|
-
- spec/ruact/server_functions/introspection_spec.rb
|
|
211
|
-
- spec/ruact/server_functions/name_bridge_spec.rb
|
|
212
|
-
- spec/ruact/server_functions/query_context_spec.rb
|
|
213
|
-
- spec/ruact/server_functions/query_source_spec.rb
|
|
214
|
-
- spec/ruact/server_functions/railtie_integration_spec.rb
|
|
215
|
-
- spec/ruact/server_functions/rake_spec.rb
|
|
216
|
-
- spec/ruact/server_functions/route_source_spec.rb
|
|
217
|
-
- spec/ruact/server_functions/snapshot_spec.rb
|
|
218
|
-
- spec/ruact/server_functions/snapshot_writer_spec.rb
|
|
219
|
-
- spec/ruact/server_rescue_request_spec.rb
|
|
220
|
-
- spec/ruact/server_spec.rb
|
|
221
|
-
- spec/ruact/server_upload_request_spec.rb
|
|
222
|
-
- spec/ruact/signed_references_spec.rb
|
|
223
|
-
- spec/ruact/string_distance_spec.rb
|
|
224
|
-
- spec/ruact/tasks_json_introspection_spec.rb
|
|
225
|
-
- spec/ruact/testing/have_ruact_component_spec.rb
|
|
226
|
-
- spec/ruact/testing/no_production_load_spec.rb
|
|
227
|
-
- spec/ruact/validation_errors_spec.rb
|
|
228
|
-
- spec/ruact/view_helper_spec.rb
|
|
229
|
-
- spec/spec_helper.rb
|
|
230
|
-
- spec/support/fixtures/pixel.png
|
|
231
|
-
- spec/support/flight_wire_parser.rb
|
|
232
|
-
- spec/support/flight_wire_parser_spec.rb
|
|
233
|
-
- spec/support/matchers/flight_fixture_matcher.rb
|
|
234
|
-
- spec/support/matchers/flight_fixture_matcher_spec.rb
|
|
235
|
-
- spec/support/rails_stub.rb
|
|
236
134
|
- vendor/javascript/ruact-server-functions-runtime/index.d.ts
|
|
237
135
|
- vendor/javascript/ruact-server-functions-runtime/index.js
|
|
238
136
|
- vendor/javascript/ruact-server-functions-runtime/index.test.mjs
|
|
239
137
|
- vendor/javascript/ruact-server-functions-runtime/package.json
|
|
240
138
|
- vendor/javascript/ruact-server-functions-runtime/usequery.test.mjs
|
|
241
139
|
- vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs
|
|
140
|
+
- vendor/javascript/vite-plugin-ruact/flight-client.test.mjs
|
|
242
141
|
- vendor/javascript/vite-plugin-ruact/index.js
|
|
243
142
|
- vendor/javascript/vite-plugin-ruact/manifest-contract.test.mjs
|
|
244
143
|
- vendor/javascript/vite-plugin-ruact/manifest-http.test.mjs
|
data/.codecov.yml
DELETED
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
# Codecov configuration for the ruact gem.
|
|
2
|
-
# Story 6.7 — coverage is informativo (not a CI gate); Codecov reports status
|
|
3
|
-
# but never fails a PR. Diff coverage targets (90% line / 80% branch) are
|
|
4
|
-
# project DoD, surfaced via the per-PR comment for visibility.
|
|
5
|
-
|
|
6
|
-
coverage:
|
|
7
|
-
status:
|
|
8
|
-
project:
|
|
9
|
-
default:
|
|
10
|
-
informational: true
|
|
11
|
-
target: auto
|
|
12
|
-
threshold: 1%
|
|
13
|
-
patch:
|
|
14
|
-
default:
|
|
15
|
-
informational: true
|
|
16
|
-
target: 90%
|
|
17
|
-
threshold: 0%
|
|
18
|
-
|
|
19
|
-
comment:
|
|
20
|
-
layout: "diff, files"
|
|
21
|
-
behavior: default
|
|
22
|
-
require_changes: false
|
|
23
|
-
|
|
24
|
-
flags:
|
|
25
|
-
gem:
|
|
26
|
-
# Paths are relative to the gem repo root (where this CI runs and uploads
|
|
27
|
-
# from). lcov.info emits source paths like `./lib/ruact.rb`; Codecov
|
|
28
|
-
# normalizes the leading `./` so a `lib/` prefix matches.
|
|
29
|
-
paths:
|
|
30
|
-
- lib/
|
|
31
|
-
carryforward: false
|