ruact 0.0.4 → 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.
Files changed (132) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +54 -2
  3. data/.rubocop_todo.yml +3 -115
  4. data/CHANGELOG.md +68 -17
  5. data/bench/server_functions_dispatch_bench.rb +109 -142
  6. data/bench/server_functions_dispatch_bench.results.md +29 -0
  7. data/docs/internal/decisions/server-functions-api.md +402 -0
  8. data/lib/generators/ruact/install/install_generator.rb +310 -25
  9. data/lib/generators/ruact/install/templates/Procfile.dev.tt +2 -0
  10. data/lib/generators/ruact/install/templates/dev.tt +16 -0
  11. data/lib/generators/ruact/install/templates/package.json.tt +17 -0
  12. data/lib/generators/ruact/install/templates/vite.config.js.tt +3 -1
  13. data/lib/generators/ruact/scaffold/scaffold_attribute.rb +114 -0
  14. data/lib/generators/ruact/scaffold/scaffold_form_helpers.rb +114 -0
  15. data/lib/generators/ruact/scaffold/scaffold_generator.rb +491 -0
  16. data/lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb +229 -0
  17. data/lib/generators/ruact/scaffold/templates/components/DeleteDialog.tsx.tt +104 -0
  18. data/lib/generators/ruact/scaffold/templates/components/Form.tsx.tt +212 -0
  19. data/lib/generators/ruact/scaffold/templates/components/List.tsx.tt +338 -0
  20. data/lib/generators/ruact/scaffold/templates/components/agnostic/DeleteDialog.tsx.tt +92 -0
  21. data/lib/generators/ruact/scaffold/templates/components/agnostic/Form.tsx.tt +174 -0
  22. data/lib/generators/ruact/scaffold/templates/components/agnostic/List.tsx.tt +253 -0
  23. data/lib/generators/ruact/scaffold/templates/controller.rb.tt +130 -0
  24. data/lib/generators/ruact/scaffold/templates/queries/application_query.rb.tt +13 -0
  25. data/lib/generators/ruact/scaffold/templates/queries/query.rb.tt +59 -0
  26. data/lib/generators/ruact/scaffold/templates/request_spec.rb.tt +77 -0
  27. data/lib/generators/ruact/scaffold/templates/views/edit.html.erb.tt +7 -0
  28. data/lib/generators/ruact/scaffold/templates/views/index.html.erb.tt +6 -0
  29. data/lib/generators/ruact/scaffold/templates/views/new.html.erb.tt +5 -0
  30. data/lib/generators/ruact/scaffold/templates/views/show.html.erb.tt +16 -0
  31. data/lib/ruact/client_manifest.rb +37 -36
  32. data/lib/ruact/component_contract.rb +115 -0
  33. data/lib/ruact/configuration.rb +80 -28
  34. data/lib/ruact/controller.rb +69 -421
  35. data/lib/ruact/doctor.rb +133 -4
  36. data/lib/ruact/erb_preprocessor.rb +65 -12
  37. data/lib/ruact/erb_preprocessor_hook.rb +4 -1
  38. data/lib/ruact/errors.rb +30 -44
  39. data/lib/ruact/html_converter.rb +22 -1
  40. data/lib/ruact/railtie.rb +56 -200
  41. data/lib/ruact/server.rb +28 -6
  42. data/lib/ruact/server_functions/codegen.rb +49 -188
  43. data/lib/ruact/server_functions/codegen_v2.rb +23 -6
  44. data/lib/ruact/server_functions/codegen_v2_query_params.rb +140 -0
  45. data/lib/ruact/server_functions/error_payload.rb +1 -1
  46. data/lib/ruact/server_functions/error_rendering.rb +22 -29
  47. data/lib/ruact/server_functions/name_bridge.rb +14 -16
  48. data/lib/ruact/server_functions/query_source.rb +35 -8
  49. data/lib/ruact/server_functions/route_source.rb +3 -4
  50. data/lib/ruact/server_functions/snapshot.rb +12 -139
  51. data/lib/ruact/server_functions/validation_errors.rb +70 -0
  52. data/lib/ruact/server_functions.rb +21 -25
  53. data/lib/ruact/signed_references.rb +162 -0
  54. data/lib/ruact/string_distance.rb +72 -0
  55. data/lib/ruact/validation_errors_collector.rb +139 -0
  56. data/lib/ruact/version.rb +1 -1
  57. data/lib/ruact/view_helper.rb +102 -0
  58. data/lib/ruact.rb +19 -19
  59. data/lib/tasks/ruact.rake +10 -53
  60. data/spec/fixtures/story_7_9_views/controller_request_spec_support/errors_demo/new.html.erb +3 -0
  61. data/spec/ruact/client_manifest_spec.rb +36 -0
  62. data/spec/ruact/component_contract_spec.rb +119 -0
  63. data/spec/ruact/configuration_spec.rb +51 -34
  64. data/spec/ruact/controller_request_spec.rb +264 -0
  65. data/spec/ruact/controller_spec.rb +63 -326
  66. data/spec/ruact/doctor_spec.rb +201 -0
  67. data/spec/ruact/erb_preprocessor_hook_spec.rb +4 -1
  68. data/spec/ruact/erb_preprocessor_spec.rb +127 -0
  69. data/spec/ruact/errors_spec.rb +0 -45
  70. data/spec/ruact/html_converter_spec.rb +50 -0
  71. data/spec/ruact/install_generator_spec.rb +591 -4
  72. data/spec/ruact/query_request_spec.rb +109 -1
  73. data/spec/ruact/scaffold_generator_spec.rb +1835 -0
  74. data/spec/ruact/server_bucket_request_spec.rb +142 -0
  75. data/spec/ruact/server_function_name_spec.rb +1 -1
  76. data/spec/ruact/server_functions/codegen_spec.rb +158 -269
  77. data/spec/ruact/server_functions/name_bridge_spec.rb +9 -9
  78. data/spec/ruact/server_functions/query_source_spec.rb +51 -0
  79. data/spec/ruact/server_functions/railtie_integration_spec.rb +71 -268
  80. data/spec/ruact/server_functions/rake_spec.rb +29 -29
  81. data/spec/ruact/server_functions/snapshot_spec.rb +53 -213
  82. data/spec/ruact/server_rescue_request_spec.rb +6 -6
  83. data/spec/ruact/server_spec.rb +8 -9
  84. data/spec/ruact/signed_references_spec.rb +164 -0
  85. data/spec/ruact/string_distance_spec.rb +38 -0
  86. data/spec/ruact/validation_errors_spec.rb +116 -0
  87. data/spec/ruact/view_helper_spec.rb +79 -0
  88. data/spec/spec_helper.rb +0 -5
  89. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +44 -47
  90. data/vendor/javascript/ruact-server-functions-runtime/index.js +151 -107
  91. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +72 -75
  92. data/vendor/javascript/ruact-server-functions-runtime/package.json +2 -2
  93. data/vendor/javascript/ruact-server-functions-runtime/usequery.test.mjs +187 -0
  94. data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +96 -0
  95. data/vendor/javascript/vite-plugin-ruact/index.js +353 -7
  96. data/vendor/javascript/vite-plugin-ruact/manifest-contract.test.mjs +182 -0
  97. data/vendor/javascript/vite-plugin-ruact/package-lock.json +16 -0
  98. data/vendor/javascript/vite-plugin-ruact/package.json +3 -1
  99. data/vendor/javascript/vite-plugin-ruact/registry.test.mjs +199 -0
  100. data/vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx +68 -0
  101. data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +235 -0
  102. data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +473 -0
  103. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +114 -139
  104. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +199 -262
  105. data/vendor/javascript/vite-plugin-ruact/tsconfig.json +18 -0
  106. data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold-agnostic.json +18 -0
  107. data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold.json +20 -0
  108. data/vendor/javascript/vite-plugin-ruact/type-tests/emitted-module.test-d.ts +23 -0
  109. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostDeleteDialog.tsx +90 -0
  110. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostForm.tsx +238 -0
  111. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostList.tsx +339 -0
  112. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostDeleteDialog.tsx +85 -0
  113. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostForm.tsx +216 -0
  114. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostList.tsx +269 -0
  115. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/ambient.d.ts +78 -0
  116. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/ambient.d.ts +166 -0
  117. data/vendor/javascript/vite-plugin-ruact/type-tests/typed-query.test-d.ts +48 -0
  118. data/vendor/javascript/vite-plugin-ruact/type-tests/usequery.test-d.ts +26 -0
  119. metadata +55 -15
  120. data/lib/generators/ruact/install/templates/application.jsx.tt +0 -51
  121. data/lib/ruact/server_action.rb +0 -131
  122. data/lib/ruact/server_functions/endpoint_controller.rb +0 -237
  123. data/lib/ruact/server_functions/registry.rb +0 -148
  124. data/lib/ruact/server_functions/registry_entry.rb +0 -26
  125. data/lib/ruact/server_functions/standalone_context.rb +0 -103
  126. data/lib/ruact/server_functions/standalone_dispatcher.rb +0 -178
  127. data/spec/ruact/server_functions/csrf_request_spec.rb +0 -380
  128. data/spec/ruact/server_functions/dispatch_request_spec.rb +0 -819
  129. data/spec/ruact/server_functions/registry_spec.rb +0 -199
  130. data/spec/ruact/server_functions/standalone_action_spec.rb +0 -224
  131. data/spec/ruact/server_functions/standalone_context_spec.rb +0 -142
  132. data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +0 -273
@@ -0,0 +1,182 @@
1
+ // Story 13.5 (FR100) — buildManifest contract extraction.
2
+ //
3
+ // A component opts into a call-site contract by exporting `__ruactContract`.
4
+ // The scanner extracts it NAMES-ONLY into an optional `contract` field on the
5
+ // manifest entry. A component without the export emits NO `contract` field
6
+ // (byte-additive). A malformed declaration is warned + skipped (fail open).
7
+
8
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
9
+ import fs from "node:fs";
10
+ import os from "node:os";
11
+ import path from "node:path";
12
+ import { buildManifest, extractContract } from "./index.js";
13
+
14
+ let dir;
15
+ beforeEach(() => {
16
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), "ruact-13-5-"));
17
+ });
18
+ afterEach(() => {
19
+ fs.rmSync(dir, { recursive: true, force: true });
20
+ });
21
+
22
+ function writeComponent(name, body) {
23
+ fs.writeFileSync(path.join(dir, name), body);
24
+ }
25
+
26
+ describe("buildManifest — contract extraction (Story 13.5)", () => {
27
+ it("attaches a `contract` field when the component exports __ruactContract", () => {
28
+ writeComponent(
29
+ "LikeButton.tsx",
30
+ [
31
+ '"use client";',
32
+ "export const __ruactContract = {",
33
+ ' props: { postId: "required", initialCount: "optional" },',
34
+ "};",
35
+ "export function LikeButton() { return null; }",
36
+ ].join("\n")
37
+ );
38
+
39
+ const manifest = buildManifest(dir);
40
+
41
+ expect(manifest.LikeButton.contract).toEqual({
42
+ props: { postId: "required", initialCount: "optional" },
43
+ });
44
+ // The base entry shape is untouched.
45
+ expect(manifest.LikeButton.id).toBe("/LikeButton.tsx");
46
+ expect(manifest.LikeButton.name).toBe("LikeButton");
47
+ });
48
+
49
+ it("emits NO `contract` field when the component declares none (byte-additive)", () => {
50
+ writeComponent(
51
+ "Plain.tsx",
52
+ ['"use client";', "export function Plain() { return null; }"].join("\n")
53
+ );
54
+
55
+ const manifest = buildManifest(dir);
56
+
57
+ expect(manifest.Plain).toBeDefined();
58
+ expect("contract" in manifest.Plain).toBe(false);
59
+ });
60
+
61
+ it("extracts slots (object form) and passthrough", () => {
62
+ writeComponent(
63
+ "Card.tsx",
64
+ [
65
+ '"use client";',
66
+ "export const __ruactContract = {",
67
+ ' props: { title: "required" },',
68
+ ' slots: { header: "optional", footer: "required" },',
69
+ " passthrough: true,",
70
+ "};",
71
+ "export const Card = () => null;",
72
+ ].join("\n")
73
+ );
74
+
75
+ const manifest = buildManifest(dir);
76
+
77
+ expect(manifest.Card.contract).toEqual({
78
+ props: { title: "required" },
79
+ slots: { header: "optional", footer: "required" },
80
+ passthrough: true,
81
+ });
82
+ });
83
+
84
+ it("extracts slots in array form (all optional)", () => {
85
+ const c = extractContract(
86
+ [
87
+ "export const __ruactContract = {",
88
+ ' props: { title: "required" },',
89
+ ' slots: ["header", "footer"],',
90
+ "};",
91
+ ].join("\n"),
92
+ "Card.tsx"
93
+ );
94
+ expect(c).toEqual({
95
+ props: { title: "required" },
96
+ slots: { header: "optional", footer: "optional" },
97
+ });
98
+ });
99
+
100
+ it("handles nested braces in prop values without breaking balance", () => {
101
+ const c = extractContract(
102
+ [
103
+ "export const __ruactContract = {",
104
+ " props: { a: { required: true }, b: { required: false } },",
105
+ "};",
106
+ ].join("\n"),
107
+ "Nested.tsx"
108
+ );
109
+ expect(c).toEqual({ props: { a: "required", b: "optional" } });
110
+ });
111
+
112
+ it("ignores braces inside comments and strings when balancing", () => {
113
+ const c = extractContract(
114
+ [
115
+ "export const __ruactContract = {",
116
+ " // a stray brace in a comment { should not break balance",
117
+ ' props: { label: "required" }, // trailing }',
118
+ ' slots: ["a-}-slot"],',
119
+ "};",
120
+ "export const X = () => null;",
121
+ ].join("\n"),
122
+ "Commented.tsx"
123
+ );
124
+ expect(c).toEqual({
125
+ props: { label: "required" },
126
+ slots: { "a-}-slot": "optional" },
127
+ });
128
+ });
129
+
130
+ it("skips the contract for a multi-component file (warn) — cannot attribute it", () => {
131
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
132
+ writeComponent(
133
+ "Pair.tsx",
134
+ [
135
+ '"use client";',
136
+ 'export const __ruactContract = { props: { x: "required" } };',
137
+ "export function Primary() { return null; }",
138
+ "export function Secondary() { return null; }",
139
+ ].join("\n")
140
+ );
141
+ const manifest = buildManifest(dir);
142
+ expect("contract" in manifest.Primary).toBe(false);
143
+ expect("contract" in manifest.Secondary).toBe(false);
144
+ expect(warn).toHaveBeenCalledOnce();
145
+ warn.mockRestore();
146
+ });
147
+
148
+ it("warns and skips a malformed declaration (unbalanced braces) → null", () => {
149
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
150
+ const c = extractContract(
151
+ "export const __ruactContract = { props: { a: 'required' ",
152
+ "Broken.tsx"
153
+ );
154
+ expect(c).toBeNull();
155
+ expect(warn).toHaveBeenCalledOnce();
156
+ warn.mockRestore();
157
+ });
158
+
159
+ it("warns and skips an information-less declaration → null", () => {
160
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
161
+ const c = extractContract("export const __ruactContract = {};", "Empty.tsx");
162
+ expect(c).toBeNull();
163
+ warn.mockRestore();
164
+ });
165
+
166
+ it("returns null when there is no __ruactContract export", () => {
167
+ expect(extractContract("export const Foo = 1;", "None.tsx")).toBeNull();
168
+ });
169
+
170
+ it("does not treat __ruactContract itself as a component export", () => {
171
+ writeComponent(
172
+ "Widget.tsx",
173
+ [
174
+ '"use client";',
175
+ 'export const __ruactContract = { props: { x: "required" } };',
176
+ "export function Widget() { return null; }",
177
+ ].join("\n")
178
+ );
179
+ const manifest = buildManifest(dir);
180
+ expect(Object.keys(manifest)).toEqual(["Widget"]);
181
+ });
182
+ });
@@ -9,6 +9,7 @@
9
9
  "version": "0.0.0-bundled",
10
10
  "license": "MIT",
11
11
  "devDependencies": {
12
+ "typescript": "^5.5.0",
12
13
  "vitest": "^2.0.0"
13
14
  }
14
15
  },
@@ -1259,12 +1260,27 @@
1259
1260
  "node": ">=14.0.0"
1260
1261
  }
1261
1262
  },
1263
+ "node_modules/typescript": {
1264
+ "version": "5.9.3",
1265
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
1266
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
1267
+ "dev": true,
1268
+ "license": "Apache-2.0",
1269
+ "bin": {
1270
+ "tsc": "bin/tsc",
1271
+ "tsserver": "bin/tsserver"
1272
+ },
1273
+ "engines": {
1274
+ "node": ">=14.17"
1275
+ }
1276
+ },
1262
1277
  "node_modules/vite": {
1263
1278
  "version": "5.4.21",
1264
1279
  "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
1265
1280
  "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
1266
1281
  "dev": true,
1267
1282
  "license": "MIT",
1283
+ "peer": true,
1268
1284
  "dependencies": {
1269
1285
  "esbuild": "^0.21.3",
1270
1286
  "postcss": "^8.4.43",
@@ -5,9 +5,11 @@
5
5
  "type": "module",
6
6
  "main": "index.js",
7
7
  "scripts": {
8
- "test": "vitest run"
8
+ "test": "vitest run",
9
+ "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.scaffold.json && tsc --noEmit -p tsconfig.scaffold-agnostic.json"
9
10
  },
10
11
  "devDependencies": {
12
+ "typescript": "^5.5.0",
11
13
  "vitest": "^2.0.0"
12
14
  },
13
15
  "license": "MIT",
@@ -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
+ }