ruact 0.0.5 → 0.0.6
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/.github/workflows/ci.yml +54 -2
- data/.rubocop_todo.yml +3 -115
- data/CHANGELOG.md +67 -18
- data/bench/server_functions_dispatch_bench.rb +109 -142
- data/bench/server_functions_dispatch_bench.results.md +29 -0
- data/docs/internal/decisions/server-functions-api.md +402 -0
- data/lib/generators/ruact/install/install_generator.rb +310 -25
- data/lib/generators/ruact/install/templates/Procfile.dev.tt +2 -0
- data/lib/generators/ruact/install/templates/dev.tt +16 -0
- data/lib/generators/ruact/install/templates/package.json.tt +17 -0
- data/lib/generators/ruact/install/templates/vite.config.js.tt +3 -1
- data/lib/generators/ruact/scaffold/scaffold_attribute.rb +114 -0
- data/lib/generators/ruact/scaffold/scaffold_form_helpers.rb +114 -0
- data/lib/generators/ruact/scaffold/scaffold_generator.rb +491 -0
- data/lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb +229 -0
- data/lib/generators/ruact/scaffold/templates/components/DeleteDialog.tsx.tt +104 -0
- data/lib/generators/ruact/scaffold/templates/components/Form.tsx.tt +212 -0
- data/lib/generators/ruact/scaffold/templates/components/List.tsx.tt +338 -0
- data/lib/generators/ruact/scaffold/templates/components/agnostic/DeleteDialog.tsx.tt +92 -0
- data/lib/generators/ruact/scaffold/templates/components/agnostic/Form.tsx.tt +174 -0
- data/lib/generators/ruact/scaffold/templates/components/agnostic/List.tsx.tt +253 -0
- data/lib/generators/ruact/scaffold/templates/controller.rb.tt +130 -0
- data/lib/generators/ruact/scaffold/templates/queries/application_query.rb.tt +13 -0
- data/lib/generators/ruact/scaffold/templates/queries/query.rb.tt +59 -0
- data/lib/generators/ruact/scaffold/templates/request_spec.rb.tt +77 -0
- data/lib/generators/ruact/scaffold/templates/views/edit.html.erb.tt +7 -0
- data/lib/generators/ruact/scaffold/templates/views/index.html.erb.tt +6 -0
- data/lib/generators/ruact/scaffold/templates/views/new.html.erb.tt +5 -0
- data/lib/generators/ruact/scaffold/templates/views/show.html.erb.tt +16 -0
- data/lib/ruact/client_manifest.rb +37 -36
- data/lib/ruact/component_contract.rb +115 -0
- data/lib/ruact/configuration.rb +80 -28
- data/lib/ruact/controller.rb +69 -421
- data/lib/ruact/doctor.rb +133 -4
- data/lib/ruact/erb_preprocessor.rb +65 -12
- data/lib/ruact/erb_preprocessor_hook.rb +4 -1
- data/lib/ruact/errors.rb +30 -44
- data/lib/ruact/html_converter.rb +22 -1
- data/lib/ruact/railtie.rb +56 -200
- data/lib/ruact/server.rb +28 -6
- data/lib/ruact/server_functions/codegen.rb +49 -188
- data/lib/ruact/server_functions/codegen_v2.rb +23 -6
- data/lib/ruact/server_functions/codegen_v2_query_params.rb +140 -0
- data/lib/ruact/server_functions/error_payload.rb +1 -1
- data/lib/ruact/server_functions/error_rendering.rb +22 -29
- data/lib/ruact/server_functions/name_bridge.rb +14 -16
- data/lib/ruact/server_functions/query_source.rb +35 -8
- data/lib/ruact/server_functions/route_source.rb +3 -4
- data/lib/ruact/server_functions/snapshot.rb +12 -139
- data/lib/ruact/server_functions/validation_errors.rb +70 -0
- data/lib/ruact/server_functions.rb +21 -25
- data/lib/ruact/signed_references.rb +162 -0
- data/lib/ruact/string_distance.rb +72 -0
- data/lib/ruact/validation_errors_collector.rb +139 -0
- data/lib/ruact/version.rb +1 -1
- data/lib/ruact/view_helper.rb +102 -0
- data/lib/ruact.rb +19 -19
- data/lib/tasks/ruact.rake +10 -53
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/errors_demo/new.html.erb +3 -0
- data/spec/ruact/client_manifest_spec.rb +36 -0
- data/spec/ruact/component_contract_spec.rb +119 -0
- data/spec/ruact/configuration_spec.rb +51 -34
- data/spec/ruact/controller_request_spec.rb +264 -0
- data/spec/ruact/controller_spec.rb +63 -326
- data/spec/ruact/doctor_spec.rb +201 -0
- data/spec/ruact/erb_preprocessor_hook_spec.rb +4 -1
- data/spec/ruact/erb_preprocessor_spec.rb +127 -0
- data/spec/ruact/errors_spec.rb +0 -45
- data/spec/ruact/html_converter_spec.rb +50 -0
- data/spec/ruact/install_generator_spec.rb +591 -4
- data/spec/ruact/query_request_spec.rb +109 -1
- data/spec/ruact/scaffold_generator_spec.rb +1835 -0
- data/spec/ruact/server_bucket_request_spec.rb +142 -0
- data/spec/ruact/server_function_name_spec.rb +1 -1
- data/spec/ruact/server_functions/codegen_spec.rb +158 -269
- data/spec/ruact/server_functions/name_bridge_spec.rb +9 -9
- data/spec/ruact/server_functions/query_source_spec.rb +51 -0
- data/spec/ruact/server_functions/railtie_integration_spec.rb +71 -268
- data/spec/ruact/server_functions/rake_spec.rb +29 -29
- data/spec/ruact/server_functions/snapshot_spec.rb +53 -213
- data/spec/ruact/server_rescue_request_spec.rb +6 -6
- data/spec/ruact/server_spec.rb +8 -9
- data/spec/ruact/signed_references_spec.rb +164 -0
- data/spec/ruact/string_distance_spec.rb +38 -0
- data/spec/ruact/validation_errors_spec.rb +116 -0
- data/spec/ruact/view_helper_spec.rb +79 -0
- data/spec/spec_helper.rb +0 -5
- data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +40 -45
- data/vendor/javascript/ruact-server-functions-runtime/index.js +42 -98
- data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +72 -75
- data/vendor/javascript/ruact-server-functions-runtime/package.json +1 -1
- data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +96 -0
- data/vendor/javascript/vite-plugin-ruact/index.js +353 -7
- data/vendor/javascript/vite-plugin-ruact/manifest-contract.test.mjs +182 -0
- data/vendor/javascript/vite-plugin-ruact/package-lock.json +16 -0
- data/vendor/javascript/vite-plugin-ruact/package.json +3 -1
- data/vendor/javascript/vite-plugin-ruact/registry.test.mjs +199 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx +68 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +235 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +473 -0
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +114 -139
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +199 -262
- data/vendor/javascript/vite-plugin-ruact/tsconfig.json +18 -0
- data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold-agnostic.json +18 -0
- data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold.json +20 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/emitted-module.test-d.ts +23 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostDeleteDialog.tsx +90 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostForm.tsx +238 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostList.tsx +339 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostDeleteDialog.tsx +85 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostForm.tsx +216 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostList.tsx +269 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/ambient.d.ts +78 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/ambient.d.ts +166 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/typed-query.test-d.ts +48 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/usequery.test-d.ts +26 -0
- metadata +55 -15
- data/lib/generators/ruact/install/templates/application.jsx.tt +0 -51
- data/lib/ruact/server_action.rb +0 -131
- data/lib/ruact/server_functions/endpoint_controller.rb +0 -237
- data/lib/ruact/server_functions/registry.rb +0 -148
- data/lib/ruact/server_functions/registry_entry.rb +0 -26
- data/lib/ruact/server_functions/standalone_context.rb +0 -103
- data/lib/ruact/server_functions/standalone_dispatcher.rb +0 -178
- data/spec/ruact/server_functions/csrf_request_spec.rb +0 -380
- data/spec/ruact/server_functions/dispatch_request_spec.rb +0 -819
- data/spec/ruact/server_functions/registry_spec.rb +0 -199
- data/spec/ruact/server_functions/standalone_action_spec.rb +0 -224
- data/spec/ruact/server_functions/standalone_context_spec.rb +0 -142
- data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +0 -273
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// Story 10.1b (component auto-registry) — the build emits MODULE_REGISTRY.
|
|
2
|
+
//
|
|
3
|
+
// vite-plugin-ruact exposes a virtual module (`virtual:ruact/registry`) whose
|
|
4
|
+
// default export is `{ [manifest id]: moduleExports }`, derived from the SAME
|
|
5
|
+
// scan that writes react-client-manifest.json. This file covers:
|
|
6
|
+
//
|
|
7
|
+
// 1. generateRegistrySource — the pure renderer (keys == manifest ids,
|
|
8
|
+
// one import per source file, membership == manifest membership).
|
|
9
|
+
// 2. dev id-match — registry keys equal the manifest `id` the gem serializes
|
|
10
|
+
// as the Flight moduleId (source-relative in dev).
|
|
11
|
+
// 3. prod id-match (LOAD-BEARING, AC2/NFR16) — a REAL `vite build` through the
|
|
12
|
+
// plugin: the emitted manifest ids equal the built registry keys, proving
|
|
13
|
+
// dev/prod parity (the eager registry inlines components, so the
|
|
14
|
+
// hashed-URL rewrite never fires and the id stays source-relative in prod).
|
|
15
|
+
|
|
16
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
17
|
+
import fs from "node:fs";
|
|
18
|
+
import os from "node:os";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import { pathToFileURL } from "node:url";
|
|
21
|
+
import { build } from "vite";
|
|
22
|
+
import {
|
|
23
|
+
buildManifest,
|
|
24
|
+
generateRegistrySource,
|
|
25
|
+
REGISTRY_VIRTUAL_ID,
|
|
26
|
+
} from "./index.js";
|
|
27
|
+
import ruact from "./index.js";
|
|
28
|
+
|
|
29
|
+
let dir;
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
// realpath so the dir matches Rollup's symlink-resolved `facadeModuleId`
|
|
32
|
+
// (macOS /tmp → /private/tmp); the build's id-rewrite match keys off it.
|
|
33
|
+
dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "ruact-10-1b-")));
|
|
34
|
+
});
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
function write(rel, body) {
|
|
40
|
+
const full = path.join(dir, rel);
|
|
41
|
+
fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
42
|
+
fs.writeFileSync(full, body);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const USE_CLIENT = '"use client";';
|
|
46
|
+
|
|
47
|
+
describe("generateRegistrySource (Story 10.1b)", () => {
|
|
48
|
+
it("keys the registry on the manifest `id` and imports the source file", () => {
|
|
49
|
+
write("PostList.jsx", [USE_CLIENT, "export function PostList() { return null; }"].join("\n"));
|
|
50
|
+
const manifest = buildManifest(dir);
|
|
51
|
+
const src = generateRegistrySource(manifest);
|
|
52
|
+
|
|
53
|
+
// Key == the manifest id the gem serializes as the Flight moduleId.
|
|
54
|
+
expect(manifest.PostList.id).toBe("/PostList.jsx");
|
|
55
|
+
expect(src).toContain('"/PostList.jsx":');
|
|
56
|
+
// Imports the absolute source file (forward-slashed specifier).
|
|
57
|
+
expect(src).toContain(`import * as __ruact_m0 from "${path.join(dir, "PostList.jsx").replace(/\\/g, "/")}"`);
|
|
58
|
+
expect(src).toContain("export default MODULE_REGISTRY;");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("emits exactly one import per source file even with multiple exports", () => {
|
|
62
|
+
write(
|
|
63
|
+
"Pair.jsx",
|
|
64
|
+
[USE_CLIENT, "export function Primary() { return null; }", "export function Secondary() { return null; }"].join("\n")
|
|
65
|
+
);
|
|
66
|
+
const src = generateRegistrySource(buildManifest(dir));
|
|
67
|
+
// Both exports share the same id ("/Pair.jsx") → one import, one entry.
|
|
68
|
+
expect((src.match(/import \* as/g) || []).length).toBe(1);
|
|
69
|
+
expect((src.match(/"\/Pair\.jsx":/g) || []).length).toBe(1);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("registry membership equals manifest membership (use-client only, .jsx/.tsx, nested)", () => {
|
|
73
|
+
write("Client.jsx", [USE_CLIENT, "export function Client() { return null; }"].join("\n"));
|
|
74
|
+
write("Typed.tsx", [USE_CLIENT, "export function Typed() { return null; }"].join("\n"));
|
|
75
|
+
write("ui/Button.jsx", [USE_CLIENT, "export function Button() { return null; }"].join("\n"));
|
|
76
|
+
// NOT registered: no "use client" directive.
|
|
77
|
+
write("ServerOnly.jsx", ["export function ServerOnly() { return null; }"].join("\n"));
|
|
78
|
+
|
|
79
|
+
const manifest = buildManifest(dir);
|
|
80
|
+
const src = generateRegistrySource(manifest);
|
|
81
|
+
|
|
82
|
+
expect(src).toContain('"/Client.jsx":');
|
|
83
|
+
expect(src).toContain('"/Typed.tsx":');
|
|
84
|
+
expect(src).toContain('"/ui/Button.jsx":'); // nested path preserved
|
|
85
|
+
expect(src).not.toContain("ServerOnly");
|
|
86
|
+
// Exactly the manifest ids, nothing more.
|
|
87
|
+
const ids = new Set(Object.values(manifest).map((e) => e.id));
|
|
88
|
+
for (const id of ids) expect(src).toContain(`${JSON.stringify(id)}:`);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("renders an empty registry for a manifest with no components", () => {
|
|
92
|
+
const src = generateRegistrySource({});
|
|
93
|
+
expect(src).toContain("const MODULE_REGISTRY = {");
|
|
94
|
+
expect(src).toContain("export default MODULE_REGISTRY;");
|
|
95
|
+
expect(src).not.toContain("import * as");
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("dev id-match invariant (Story 10.1b AC2)", () => {
|
|
100
|
+
it("registry keys equal the source-relative manifest ids (dev form)", () => {
|
|
101
|
+
write("PostList.jsx", [USE_CLIENT, "export function PostList() { return null; }"].join("\n"));
|
|
102
|
+
write("ui/Button.tsx", [USE_CLIENT, "export function Button() { return null; }"].join("\n"));
|
|
103
|
+
const manifest = buildManifest(dir);
|
|
104
|
+
const src = generateRegistrySource(manifest);
|
|
105
|
+
|
|
106
|
+
for (const entry of Object.values(manifest)) {
|
|
107
|
+
// entry.id is what client_manifest.rb serializes as moduleId; the registry
|
|
108
|
+
// MUST carry that exact key so moduleRegistry[moduleId] resolves.
|
|
109
|
+
expect(src).toContain(`${JSON.stringify(entry.id)}:`);
|
|
110
|
+
}
|
|
111
|
+
expect(manifest.PostList.id).toBe("/PostList.jsx");
|
|
112
|
+
expect(manifest.Button.id).toBe("/ui/Button.tsx");
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("prod id-match invariant — real vite build (Story 10.1b AC2, LOAD-BEARING)", () => {
|
|
117
|
+
it("a built bundle resolves every manifest id through the emitted registry", async () => {
|
|
118
|
+
// Plain-JS components (no JSX/react) so the built bundle is self-contained
|
|
119
|
+
// and importable in node — the invariant under test is id↔key parity, not
|
|
120
|
+
// rendering. The eager registry inlines these into the entry chunk.
|
|
121
|
+
write("components/PostList.js", [USE_CLIENT, "export function PostList() { return 'PostList'; }"].join("\n"));
|
|
122
|
+
write("components/PostForm.js", [USE_CLIENT, "export function PostForm() { return 'PostForm'; }"].join("\n"));
|
|
123
|
+
write("components/ui/Button.js", [USE_CLIENT, "export function Button() { return 'Button'; }"].join("\n"));
|
|
124
|
+
// Entry re-exports the auto-registry so we can inspect it post-build.
|
|
125
|
+
write("entry.js", `export { default as registry } from ${JSON.stringify(REGISTRY_VIRTUAL_ID)};`);
|
|
126
|
+
|
|
127
|
+
await build({
|
|
128
|
+
root: dir,
|
|
129
|
+
logLevel: "silent",
|
|
130
|
+
plugins: [ruact({ componentsDir: "components" })],
|
|
131
|
+
build: {
|
|
132
|
+
outDir: "dist",
|
|
133
|
+
write: true,
|
|
134
|
+
minify: false,
|
|
135
|
+
rollupOptions: {
|
|
136
|
+
input: path.join(dir, "entry.js"),
|
|
137
|
+
// Keep the entry's `registry` export (a Vite app build otherwise
|
|
138
|
+
// tree-shakes unused entry exports, emptying the chunk).
|
|
139
|
+
preserveEntrySignatures: "strict",
|
|
140
|
+
output: { entryFileNames: "entry.js", format: "es" },
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// The manifest the gem will read (ids === Flight moduleIds).
|
|
146
|
+
const manifest = JSON.parse(
|
|
147
|
+
fs.readFileSync(path.join(dir, "public/react-client-manifest.json"), "utf8")
|
|
148
|
+
);
|
|
149
|
+
const manifestIds = new Set(Object.values(manifest).map((e) => e.id));
|
|
150
|
+
|
|
151
|
+
// Prod ids stay source-relative (eager-inlined components → no facade chunk
|
|
152
|
+
// → generateBundle's hashed-URL rewrite never fires). This is the dev/prod
|
|
153
|
+
// parity guarantee (NFR16).
|
|
154
|
+
expect(manifestIds).toEqual(new Set(["/PostList.js", "/PostForm.js", "/ui/Button.js"]));
|
|
155
|
+
|
|
156
|
+
// The built registry's keys equal the manifest ids exactly, and each value
|
|
157
|
+
// exposes the component export the Flight client looks up (mod[exportName]).
|
|
158
|
+
const built = await import(pathToFileURL(path.join(dir, "dist/entry.js")).href);
|
|
159
|
+
const registry = built.registry;
|
|
160
|
+
expect(new Set(Object.keys(registry))).toEqual(manifestIds);
|
|
161
|
+
for (const [name, entry] of Object.entries(manifest)) {
|
|
162
|
+
// moduleRegistry[moduleId][exportName] must be defined for every manifest entry.
|
|
163
|
+
expect(registry[entry.id][entry.name]).toBeTypeOf("function");
|
|
164
|
+
expect(registry[entry.id][entry.name]()).toBe(name);
|
|
165
|
+
}
|
|
166
|
+
}, 30000);
|
|
167
|
+
|
|
168
|
+
it("FAILS the build loudly if a component becomes a hashed facade chunk (no silent miss)", async () => {
|
|
169
|
+
// The hazardous branch: a component is ALSO a Rollup entry → it gets its own
|
|
170
|
+
// hashed facade chunk → generateBundle would rewrite its manifest id to the
|
|
171
|
+
// hashed URL while the registry still keys it by source path. The id-match
|
|
172
|
+
// guard must turn that into a build error, not a silent prod hydration miss.
|
|
173
|
+
write("components/PostList.js", [USE_CLIENT, "export function PostList() { return 'PostList'; }"].join("\n"));
|
|
174
|
+
write("entry.js", `export { default as registry } from ${JSON.stringify(REGISTRY_VIRTUAL_ID)};`);
|
|
175
|
+
|
|
176
|
+
await expect(
|
|
177
|
+
build({
|
|
178
|
+
root: dir,
|
|
179
|
+
logLevel: "silent",
|
|
180
|
+
plugins: [ruact({ componentsDir: "components" })],
|
|
181
|
+
build: {
|
|
182
|
+
outDir: "dist",
|
|
183
|
+
write: false,
|
|
184
|
+
minify: false,
|
|
185
|
+
rollupOptions: {
|
|
186
|
+
// Force the component to be its own entry → hashed facade chunk.
|
|
187
|
+
input: {
|
|
188
|
+
entry: path.join(dir, "entry.js"),
|
|
189
|
+
PostList: path.join(dir, "components/PostList.js"),
|
|
190
|
+
},
|
|
191
|
+
preserveEntrySignatures: "strict",
|
|
192
|
+
// Hashed names mirror a real prod build (the rewrite the guard catches).
|
|
193
|
+
output: { entryFileNames: "[name]-[hash].js", format: "es" },
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
})
|
|
197
|
+
).rejects.toThrow(/silent hydration miss|standalone .* chunks/i);
|
|
198
|
+
}, 30000);
|
|
199
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Ruact bootstrap entry — served to the app as the virtual module
|
|
2
|
+
// `virtual:ruact/bootstrap` (Story 14.2, FR104). This source ships INSIDE the
|
|
3
|
+
// gem so it never sits in the user's `app/javascript/` tree; the bundled
|
|
4
|
+
// vite-plugin's resolveId/load serve it (mirroring `virtual:ruact/registry`).
|
|
5
|
+
//
|
|
6
|
+
// IMPORTANT — this file is loaded as VIRTUAL-MODULE TEXT, not from disk, so its
|
|
7
|
+
// resolved id is `\0virtual:ruact/bootstrap`, which is NOT a filesystem path:
|
|
8
|
+
// • Relative imports (`./flight-client.js`) do NOT resolve from a `\0virtual:`
|
|
9
|
+
// id. The plugin's `load` hook rewrites the two runtime imports below to
|
|
10
|
+
// ABSOLUTE fs specifiers into the gem `runtime/` dir before serving them
|
|
11
|
+
// (see `generateBootstrapSource` in index.js).
|
|
12
|
+
// • Bare specifiers (`react`, `react-dom/client`) and the `virtual:` id DO
|
|
13
|
+
// resolve — Vite resolves them from the APP ROOT, so React/react-dom come
|
|
14
|
+
// from the USER's node_modules (a single React instance — non-negotiable).
|
|
15
|
+
// • No JSX syntax is used here (the inner App is built with `createElement`)
|
|
16
|
+
// so the virtual module loads through Vite's default loader exactly like
|
|
17
|
+
// `virtual:ruact/registry`, with no dependency on @vitejs/plugin-react
|
|
18
|
+
// transforming a `\0virtual:` id.
|
|
19
|
+
import { createRoot } from 'react-dom/client';
|
|
20
|
+
import { createElement, useState, useEffect } from 'react';
|
|
21
|
+
import { createFromFlightPayload } from './flight-client.js';
|
|
22
|
+
import { setupRouter, teardownRouter } from './ruact-router.js';
|
|
23
|
+
|
|
24
|
+
// MODULE_REGISTRY maps react-client-manifest "id" values to component exports.
|
|
25
|
+
// It is auto-derived by vite-plugin-ruact from the SAME scan that emits
|
|
26
|
+
// public/react-client-manifest.json — every "use client" component under
|
|
27
|
+
// app/javascript/components/ is registered automatically, so adding or removing
|
|
28
|
+
// one needs ZERO edits here. (To opt a component out, place it outside
|
|
29
|
+
// app/javascript/components/ or drop its "use client" directive.)
|
|
30
|
+
import MODULE_REGISTRY from 'virtual:ruact/registry';
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Boot
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
const flightData = globalThis.__FLIGHT_DATA;
|
|
36
|
+
|
|
37
|
+
if (!flightData || flightData.length === 0) {
|
|
38
|
+
// Non-RSC page or Rails server not running — skip hydration.
|
|
39
|
+
const root = document.getElementById('root');
|
|
40
|
+
if (root && root.childNodes.length === 0) {
|
|
41
|
+
root.textContent = '[ruact] No Flight data found — is the Rails server running?';
|
|
42
|
+
}
|
|
43
|
+
} else {
|
|
44
|
+
const payload = flightData.join('');
|
|
45
|
+
|
|
46
|
+
let initialTree;
|
|
47
|
+
try {
|
|
48
|
+
initialTree = createFromFlightPayload(payload, MODULE_REGISTRY);
|
|
49
|
+
} catch (err) {
|
|
50
|
+
console.error('[ruact] Failed to parse Flight payload:', err);
|
|
51
|
+
const root = document.getElementById('root');
|
|
52
|
+
if (root) root.textContent = '[ruact] Error: ' + err.message;
|
|
53
|
+
throw err;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function App() {
|
|
57
|
+
const [tree, setTree] = useState(() => initialTree);
|
|
58
|
+
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
setupRouter({ onNavigate: setTree, moduleRegistry: MODULE_REGISTRY });
|
|
61
|
+
return () => teardownRouter();
|
|
62
|
+
}, []);
|
|
63
|
+
|
|
64
|
+
return tree;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
createRoot(document.getElementById('root')).render(createElement(App));
|
|
68
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal React Flight wire format parser.
|
|
3
|
+
*
|
|
4
|
+
* Handles the subset we emit from the Ruby server:
|
|
5
|
+
* - Model rows: <hex_id>:<json>\n
|
|
6
|
+
* - Import rows: <hex_id>:I[moduleId, exportName, chunks]\n
|
|
7
|
+
*
|
|
8
|
+
* Returns a React element tree by recursively converting
|
|
9
|
+
* ["$", type, key, props] tuples into React.createElement calls.
|
|
10
|
+
*
|
|
11
|
+
* Supports Suspense streaming:
|
|
12
|
+
* - "$SS" element type → React.Suspense
|
|
13
|
+
* - "$L{hex}" referencing a missing row → React.lazy() that resolves when the row arrives
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { createElement, Fragment, lazy, Suspense } from "react";
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Pending chunk registry — used for streaming Suspense deferred rows
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
const pendingChunks = new Map(); // rowId → { promise, resolve }
|
|
23
|
+
const lazyCache = new Map(); // rowId → React.lazy component (memoized)
|
|
24
|
+
|
|
25
|
+
/** Clear all pending lazy refs. Call at the start of each navigation. */
|
|
26
|
+
export function clearPendingChunks() {
|
|
27
|
+
pendingChunks.clear();
|
|
28
|
+
lazyCache.clear();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Called when a deferred model row arrives during streaming.
|
|
33
|
+
* Resolves the pending lazy component so React re-renders the Suspense boundary.
|
|
34
|
+
*
|
|
35
|
+
* @param {number} rowId
|
|
36
|
+
* @param {*} element - the React element tree built from the deferred row
|
|
37
|
+
*/
|
|
38
|
+
export function resolvePendingChunk(rowId, element) {
|
|
39
|
+
const chunk = pendingChunks.get(rowId);
|
|
40
|
+
if (chunk) {
|
|
41
|
+
chunk.resolve(element);
|
|
42
|
+
pendingChunks.delete(rowId);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function createLazyForPending(rowId) {
|
|
47
|
+
if (lazyCache.has(rowId)) return lazyCache.get(rowId);
|
|
48
|
+
|
|
49
|
+
let resolve;
|
|
50
|
+
const promise = new Promise((r) => { resolve = r; });
|
|
51
|
+
pendingChunks.set(rowId, { promise, resolve });
|
|
52
|
+
|
|
53
|
+
// React.lazy expects { default: ComponentType }. We wrap the element in a function component.
|
|
54
|
+
const LazyComp = lazy(() => promise.then((el) => ({ default: () => el })));
|
|
55
|
+
lazyCache.set(rowId, LazyComp);
|
|
56
|
+
return LazyComp;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// Row parsing
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Parse a single Flight wire format line into { id, row }.
|
|
65
|
+
* Returns null for blank or malformed lines.
|
|
66
|
+
*
|
|
67
|
+
* @param {string} line
|
|
68
|
+
* @returns {{ id: number, row: object } | null}
|
|
69
|
+
*/
|
|
70
|
+
export function parseLine(line) {
|
|
71
|
+
if (!line.trim()) return null;
|
|
72
|
+
|
|
73
|
+
const colonIdx = line.indexOf(":");
|
|
74
|
+
if (colonIdx === -1) return null;
|
|
75
|
+
|
|
76
|
+
const id = parseInt(line.slice(0, colonIdx), 16);
|
|
77
|
+
const rest = line.slice(colonIdx + 1);
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
if (rest.startsWith("I")) {
|
|
81
|
+
const [moduleId, exportName] = JSON.parse(rest.slice(1));
|
|
82
|
+
return { id, row: { kind: "import", moduleId, exportName } };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (rest.startsWith("E")) {
|
|
86
|
+
const errorData = JSON.parse(rest.slice(1));
|
|
87
|
+
const message = typeof errorData === "string"
|
|
88
|
+
? errorData
|
|
89
|
+
: (errorData.message || String(errorData));
|
|
90
|
+
return { id, row: { kind: "error", message } };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return { id, row: { kind: "model", value: JSON.parse(rest) } };
|
|
94
|
+
} catch (e) {
|
|
95
|
+
console.warn("[flight-client] Skipping malformed row:", line, e);
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// Tree building
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Build a React element tree from a fully-populated rows Map.
|
|
106
|
+
*
|
|
107
|
+
* @param {Map} rows - id → { kind, ... } rows
|
|
108
|
+
* @param {Object} moduleRegistry - { [moduleId]: { [exportName]: Component } }
|
|
109
|
+
* @returns React element tree
|
|
110
|
+
*/
|
|
111
|
+
export function buildTreeFromRows(rows, moduleRegistry) {
|
|
112
|
+
const root = rows.get(0);
|
|
113
|
+
if (!root) throw new Error("[flight-client] No root row (id=0) found in payload");
|
|
114
|
+
if (root.kind === "error") throw new Error(`[ruact] Server error: ${root.message}`);
|
|
115
|
+
if (root.kind !== "model") throw new Error("[flight-client] Root row is not a model row");
|
|
116
|
+
return buildTree(root.value, rows, moduleRegistry);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Build a React element tree from a single row value.
|
|
121
|
+
* Used when resolving a deferred (Suspense) row that has arrived via streaming.
|
|
122
|
+
*
|
|
123
|
+
* @param {*} value
|
|
124
|
+
* @param {Map} rows
|
|
125
|
+
* @param {Object} moduleRegistry
|
|
126
|
+
*/
|
|
127
|
+
export function buildTree(value, rows, moduleRegistry) {
|
|
128
|
+
return _buildTree(value, rows, moduleRegistry);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Parse a Flight payload string and return a React element tree.
|
|
133
|
+
* Convenience wrapper — used for the initial (non-streaming) page load.
|
|
134
|
+
*
|
|
135
|
+
* @param {string} payload
|
|
136
|
+
* @param {Object} moduleRegistry
|
|
137
|
+
*/
|
|
138
|
+
export function createFromFlightPayload(payload, moduleRegistry) {
|
|
139
|
+
const rows = new Map();
|
|
140
|
+
for (const line of payload.split("\n")) {
|
|
141
|
+
const parsed = parseLine(line);
|
|
142
|
+
if (parsed) rows.set(parsed.id, parsed.row);
|
|
143
|
+
}
|
|
144
|
+
return buildTreeFromRows(rows, moduleRegistry);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
// Internal helpers
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
function _buildTree(value, rows, moduleRegistry) {
|
|
152
|
+
if (value === null || value === undefined) return value;
|
|
153
|
+
|
|
154
|
+
// --- Strings with special $ prefixes ---
|
|
155
|
+
if (typeof value === "string") {
|
|
156
|
+
if (value.startsWith("$$")) return value.slice(1); // escaped $
|
|
157
|
+
if (value === "$undefined") return undefined;
|
|
158
|
+
if (value === "$NaN") return NaN;
|
|
159
|
+
if (value === "$Infinity") return Infinity;
|
|
160
|
+
if (value === "$-Infinity") return -Infinity;
|
|
161
|
+
if (value === "$-0") return -0;
|
|
162
|
+
if (value.startsWith("$L")) {
|
|
163
|
+
const refId = parseInt(value.slice(2), 16);
|
|
164
|
+
const row = rows.get(refId);
|
|
165
|
+
|
|
166
|
+
if (!row) {
|
|
167
|
+
// Row hasn't arrived yet — create a lazy component that suspends until it does
|
|
168
|
+
return createLazyForPending(refId);
|
|
169
|
+
}
|
|
170
|
+
if (row.kind === "error") {
|
|
171
|
+
throw new Error(`[ruact] Server error: ${row.message}`);
|
|
172
|
+
}
|
|
173
|
+
if (row.kind === "import") {
|
|
174
|
+
const mod = moduleRegistry[row.moduleId];
|
|
175
|
+
if (!mod) throw new Error(`[flight-client] Module not registered: ${row.moduleId}`);
|
|
176
|
+
const component = mod[row.exportName];
|
|
177
|
+
if (!component) throw new Error(`[flight-client] Export "${row.exportName}" not found in ${row.moduleId}`);
|
|
178
|
+
return component;
|
|
179
|
+
}
|
|
180
|
+
// Model row — deferred content already arrived (non-streaming path).
|
|
181
|
+
// Wrap in a function component so it can be used as a type in createElement.
|
|
182
|
+
const content = _buildTree(row.value, rows, moduleRegistry);
|
|
183
|
+
return () => content;
|
|
184
|
+
}
|
|
185
|
+
return value;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// --- Arrays ---
|
|
189
|
+
if (Array.isArray(value)) {
|
|
190
|
+
// React element tuple: ["$", type, key, props]
|
|
191
|
+
if (value[0] === "$") {
|
|
192
|
+
const [, rawType, key, rawProps] = value;
|
|
193
|
+
const type = resolveType(rawType, rows, moduleRegistry);
|
|
194
|
+
const props = buildProps(rawProps, rows, moduleRegistry);
|
|
195
|
+
if (key != null) props.key = key;
|
|
196
|
+
return createElement(type, props);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Plain array / fragment children
|
|
200
|
+
const items = value.map((v) => _buildTree(v, rows, moduleRegistry));
|
|
201
|
+
return items.length === 1 ? items[0] : items;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// --- Plain objects ---
|
|
205
|
+
if (typeof value === "object") {
|
|
206
|
+
return Object.fromEntries(
|
|
207
|
+
Object.entries(value).map(([k, v]) => [k, _buildTree(v, rows, moduleRegistry)])
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return value;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function resolveType(rawType, rows, moduleRegistry) {
|
|
215
|
+
if (typeof rawType === "string") {
|
|
216
|
+
if (rawType === "$SS") return Suspense; // React.Suspense
|
|
217
|
+
if (rawType.startsWith("$L")) return _buildTree(rawType, rows, moduleRegistry); // lazy / import
|
|
218
|
+
return rawType;
|
|
219
|
+
}
|
|
220
|
+
return rawType;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function buildProps(rawProps, rows, moduleRegistry) {
|
|
224
|
+
if (!rawProps) return {};
|
|
225
|
+
const props = {};
|
|
226
|
+
for (const [key, val] of Object.entries(rawProps)) {
|
|
227
|
+
if (key === "children") {
|
|
228
|
+
const children = _buildTree(val, rows, moduleRegistry);
|
|
229
|
+
if (children !== undefined) props.children = children;
|
|
230
|
+
} else {
|
|
231
|
+
props[key] = _buildTree(val, rows, moduleRegistry);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return props;
|
|
235
|
+
}
|