react-manifest-rails 0.2.34 → 0.3.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0076f3ffaf1c303d4ac14a8345477b3851045267b0cba8f823f49ce5f17efa4f
4
- data.tar.gz: 579d04e786fe515e46d9c56c5c755efcf616cfd49289fcef8ccda0cc4cce7fa8
3
+ metadata.gz: adc189b9603ad36c8d986e8f90cac383fcb65cfabafc60d64b3155681cd21ca7
4
+ data.tar.gz: 3cd94fbc6573c1548753ece80674a42d476619f7553b4fedffdd7d3b631cbe16
5
5
  SHA512:
6
- metadata.gz: 6d221314836bc176da1378824e2ab7a55232d5977db2ca46b353d98f499b8e49d31b7999d0787953944db9c8b1c8652f3f17422918ee59d0fd5b78f5d46dca65
7
- data.tar.gz: 3eeaba00d80ad2e7c73b1320026ea4b3ed0ed989b5a3c33513a2e45f6e63f7b632b466c2cb192e163b42892f4bce76b2dc59b87b90d90869c4eaa5137f4a8a86
6
+ metadata.gz: b382bb1ae0d2d9464402dbca4cacc0294897eea15e4a9a9917c447219ad8e5788ed22a2062d4dd2f28c7635c8e09ba2629972d8fc04a0e3b22d6769d87ec7a2c
7
+ data.tar.gz: f32f2baa961dbcb50b048191ed46e81f5ea7f016994355b2cba3a332b7f846e7f889df41cbc3fefb5364e8a7c3189484f3181058d825e6935ca6fc9ddced033e
data/CHANGELOG.md CHANGED
@@ -7,6 +7,93 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.1] - 2026-10-13
11
+
12
+ ### Added
13
+ - `react_bundle_tag` emits `data-turbolinks-track="reload"` on the shared bundle's tag only
14
+ (`config.shared_bundle`). Turbolinks 5 then full-reloads once after a deploy changes
15
+ `ux_shared`, so old shared code never runs with new controller code. Controller and
16
+ `always_include` bundles stay untracked to avoid a reload on every cross-controller visit.
17
+ - `react_manifest:generate` warns when two different files declare the same top-level
18
+ `const`/`let`/`class` (destructured names included) in different bundles. Classic scripts
19
+ share one global scope, so under Turbolinks the second bundle throws "Identifier X has
20
+ already been declared" and is rejected.
21
+ - `react_manifest:generate` (and the dev boot/watch generation) writes
22
+ `ux_manifests/component_index.json`, the component-symbol -> bundle maps. `react_component`
23
+ loads it instead of parsing `ux/app` with mini_racer on each process's first call (no V8
24
+ context, no per-worker RSS cost). It is used only when its content fingerprint matches;
25
+ a missing or stale sidecar falls back to the runtime parse. Gitignored alongside the
26
+ manifests; removed by `react_manifest:clean`.
27
+ - `rake react_manifest:check`: analysis only, writes nothing, exits 1 with the warnings on
28
+ stderr when any "declared twice" clash is found (cross-bundle lexical collisions,
29
+ `always_include` double-declares). Meant for CI.
30
+
31
+ ### Fixed
32
+ - `mini_racer` is now a declared runtime dependency (`>= 0.2`); `AstExtractor` already
33
+ required it unconditionally.
34
+ - The built gem no longer ships `lib/react_manifest/vendor/build/node_modules`.
35
+ - `react_bundle_tag` now hoists bundle `<script>` tags into the layout `<head>`. Rails renders
36
+ the template before the layout, so `react_component` wrote ux_shared, `always_include` and
37
+ controller bundle tags into the `<body>`; Turbolinks re-evaluates body scripts on every
38
+ visit, giving "Identifier X has already been declared". `react_component` now marks its
39
+ inline tags (`data-react-manifest`) and `react_bundle_tag` moves them to the head, once, in
40
+ shared → always_include → controller order. Layouts without `react_bundle_tag` are unchanged.
41
+ - Collision warnings for `always_include` bundles now cover the every-page double.
42
+ Previously only a file that **directly** defined a collided symbol was warned about.
43
+ A file that is merely *tainted* (transitively depends on a collided symbol) and gets
44
+ pulled into **2+** `always_include` tags double-declares on every page but was not
45
+ warned. The warning now reports both a *multi-tag* double (a file carried by 2+
46
+ always_include tags — including tainted files, naming the file, symbol, and bundles)
47
+ and the *owner-page* double (single tag vs. the owning bundle), with wording accurate
48
+ to each case rather than always claiming the double happens "on the owner's page".
49
+
50
+ ## [0.3.0] - 2026-07-15
51
+
52
+ ### Fixed
53
+ - `always_include` no longer double-declares. Its bundles are delivered on every page
54
+ by their own `<script>` tag (both `react_bundle_tag`/`resolve_bundles` and
55
+ `react_component` now emit them), and their files are no longer inlined into each
56
+ controller manifest. Previously the separate tag plus the inline loaded an
57
+ always_include bundle's **private** files twice on any other controller's page, and a
58
+ **collided** symbol whose definer lives in an always_include bundle could pull a
59
+ second definer into the consumer — both causing "Identifier X has already been
60
+ declared". Controller dependency resolution now treats symbols provided by an
61
+ always_include bundle as already satisfied, and skips those bundles when inlining
62
+ dependencies (legacy `auto_shared = false` path included). The "already provided"
63
+ set is computed from the exact files the always_include `<script>` tag actually
64
+ carries (its own non-promoted files plus the canonical dependency files it inlines),
65
+ not from whole dependency bundles — so a consumer never drops a definer for a symbol
66
+ that lives in a sibling file the tag does not load. Symbol availability that the
67
+ previous inlining protected (added in 0.2.24) is preserved by the tag `react_component`
68
+ now emits.
69
+ - One case remains inherently unresolvable by dependency arithmetic: when an
70
+ `always_include` bundle *consumes* a collided symbol, its tag inlines the
71
+ first-writer definer, which also lives in that definer's own bundle manifest, so on
72
+ the definer's own page the file loads twice. `react_manifest:generate` now emits a
73
+ warning naming the always_include bundle, the duplicated file/symbol, and the owning
74
+ bundle, with the fix (rename or relocate the collided symbol). Detected in both the
75
+ default and legacy (`auto_shared = false`) modes.
76
+
77
+ ### Added
78
+ - Auto-promotion: a component defined under `ux/app/<controller>/` but used by any
79
+ other bundle is now emitted into `ux_shared` (loaded once per page) instead of being
80
+ inlined into each consumer. This eliminates duplicate top-level `const` declarations
81
+ ("Identifier X has already been declared") — and the cascading `X is not defined`
82
+ errors that follow when a SyntaxError aborts a concatenated bundle — for components
83
+ shared across controllers (e.g. a navbar component pulled in via `always_include`).
84
+ Files never move; only the `//= require` line's destination changes. Promotion is
85
+ transitive. `react_manifest:analyze` now lists what was promoted and why. Set
86
+ `config.auto_shared = false` to restore the previous inline behavior.
87
+
88
+ ### Changed
89
+ - Generated `ux_manifests/*.js` are now treated as build artifacts. `react_manifest:setup`
90
+ and the development boot step add them to `.gitignore` (idempotently) and keep a
91
+ committed `.keep` so the directory always exists; setup prints a one-time
92
+ `git rm --cached` to untrack previously-committed manifests. Production is unaffected
93
+ (manifests regenerate during `assets:precompile`). Opt out with
94
+ `config.manage_gitignore = false`. Upgrading an existing app requires no manual step:
95
+ the first dev boot regenerates manifests and ensures the ignore entry.
96
+
10
97
  ## [0.2.34] - 2026-07-02
11
98
 
12
99
  ### Added
data/README.md CHANGED
@@ -143,13 +143,14 @@ end
143
143
  ### Key option notes
144
144
 
145
145
  - **`ignore`**: skips entire controller dirs under `ux/app/`. `ignore = ["admin"]` excludes `ux/app/admin/`.
146
- - **`isolated_app_dirs`**: a controller dir's own `ux_<name>.js` is generated as usual, but its files/symbols are never inferred as a dependency of any *other* controller. Without the optional AST engine below (i.e. no `mini_racer`), symbol usage detection is regex-based, so a generic component name can collide with an unrelated word appearing as plain JSX text elsewhere (e.g. a `Show` component vs. a "Show More" button label in a different controller) and get wrongly inlined everywhere. Use this as a manual, always-available fallback for self-contained dirs — e.g. a page-builder tool bundling its own vendored library — that should never leak into other pages, whether or not `mini_racer` is installed. `isolated_app_dirs = ["rvb"]` isolates `ux/app/rvb/`.
146
+ - **`isolated_app_dirs`**: a controller dir's own `ux_<name>.js` is generated as usual, but its files/symbols are never inferred as a dependency of any *other* controller. When the AST engine below is unavailable (e.g. `mini_racer` failed to load), symbol usage detection is regex-based, so a generic component name can collide with an unrelated word appearing as plain JSX text elsewhere (e.g. a `Show` component vs. a "Show More" button label in a different controller) and get wrongly inlined everywhere. Use this as a manual, always-available fallback for self-contained dirs — e.g. a page-builder tool bundling its own vendored library — that should never leak into other pages, whether or not `mini_racer` is installed. `isolated_app_dirs = ["rvb"]` isolates `ux/app/rvb/`.
147
147
  - **`exclude_paths`**: excludes files whose path contains any listed segment. Not based on `application.js`.
148
148
  - **`dry_run`**: also honoured by `DRY_RUN=1` environment variable at runtime.
149
149
  - **`extensions`**: add `ts` and `tsx` to enable TypeScript source detection.
150
150
  - **`external_roots` / `external_providers`**: do not point these at files already inside shared `ux/` dirs (`components/`, `hooks/`, `lib/`, etc.). The generator now skips those overlaps to prevent duplicate declarations in browser runtime.
151
151
  - **`stdout_logging`**: `Rails.logger` always receives status lines regardless of this flag. Turn this on only if your `Rails.logger` does *not* already print to your terminal in development — if it does (common with `RAILS_LOG_TO_STDOUT`, Docker, or `bin/dev`/Foreman setups), enabling this prints every line twice.
152
- - **AST-based symbol extraction**: add `gem "mini_racer"` to your Gemfile to opt in. When present, definitions and usages are derived from a real JS/JSX AST (Acorn) instead of regex, which fixes false positives like a `Show` component colliding with unrelated text such as a "Show More" button label. Falls back to regex extraction per-file on any parse failure (always logged via `Rails.logger.warn` with file/line/column when available) — generation never breaks because of this. Without `mini_racer`, behavior is unchanged from today.
152
+ - **Component index**: `react_manifest:generate` (and dev boot/watch) also writes `ux_manifests/component_index.json`, a gitignored build artifact. `react_component` reads it instead of parsing `ux/app` with V8 on the first call in each web process. It is used only when its fingerprint (gem version, config, file list and contents) matches; otherwise the runtime parses as before.
153
+ - **AST-based symbol extraction**: `mini_racer` is a dependency of this gem. Definitions and usages are derived from a real JS/JSX AST (Acorn) instead of regex, which fixes false positives like a `Show` component colliding with unrelated text such as a "Show More" button label. Falls back to regex extraction per-file on any parse failure (always logged via `Rails.logger.warn` with file/line/column when available) — generation never breaks because of this.
153
154
 
154
155
  ## Commands
155
156
 
@@ -164,6 +165,10 @@ bundle exec rails react_manifest:generate
164
165
  DRY_RUN=1 bundle exec rails react_manifest:setup
165
166
  DRY_RUN=1 bundle exec rails react_manifest:generate
166
167
 
168
+ # CI gate: writes nothing; exits 1 and prints each "declared twice" warning to stderr
169
+ # (cross-bundle top-level const/let/class clashes, always_include double-declares)
170
+ bundle exec rails react_manifest:check
171
+
167
172
  # Analyse which shared symbols are used per controller
168
173
  bundle exec rails react_manifest:analyze
169
174
 
@@ -15,6 +15,9 @@ module ReactManifest
15
15
  function __reactManifestExtractDefinitions(content) {
16
16
  return JSON.stringify(__astExtractor.extractDefinitions(content));
17
17
  }
18
+ function __reactManifestExtractLexicalBindings(content) {
19
+ return JSON.stringify(__astExtractor.extractLexicalBindings(content));
20
+ }
18
21
  function __reactManifestExtractUsages(content) {
19
22
  return JSON.stringify(__astExtractor.extractUsages(content));
20
23
  }
@@ -31,6 +34,14 @@ module ReactManifest
31
34
  nil
32
35
  end
33
36
 
37
+ def extract_lexical_bindings(content, file_path: nil)
38
+ result = call_js("__reactManifestExtractLexicalBindings", content)
39
+ return result["bindings"] if result["success"]
40
+
41
+ report_error(file_path, result["error"])
42
+ nil
43
+ end
44
+
34
45
  def extract_usages(content, file_path: nil)
35
46
  result = call_js("__reactManifestExtractUsages", content)
36
47
  return result["usages"] if result["success"]
@@ -0,0 +1,136 @@
1
+ require "digest"
2
+ require "json"
3
+ require "set"
4
+
5
+ module ReactManifest
6
+ # The component-symbol -> bundle maps behind react_component bundle resolution.
7
+ #
8
+ # Parsing ux/app needs a V8 context (tens of MB per process), so the generator
9
+ # writes the maps to a JSON sidecar next to the generated manifests and the
10
+ # runtime loads that instead. The sidecar carries a fingerprint of everything the
11
+ # maps are derived from (gem version, config, controller dirs, file paths and
12
+ # contents); a missing, unreadable or mismatching sidecar falls back to parsing.
13
+ module ComponentIndex
14
+ FILENAME = "component_index.json".freeze
15
+
16
+ module_function
17
+
18
+ def path(config)
19
+ File.join(config.abs_manifest_dir, FILENAME)
20
+ end
21
+
22
+ # Sidecar maps when fresh, else freshly parsed ones.
23
+ def maps(config)
24
+ controller_dirs = TreeClassifier.new(config).classify.controller_dirs
25
+ load_fresh(config, controller_dirs) || build(config, controller_dirs)
26
+ end
27
+
28
+ # Write the sidecar (skipped when unchanged). Returns the path.
29
+ def write!(config, controller_dirs)
30
+ dest = path(config)
31
+ json = JSON.generate(serialize(config, controller_dirs, build(config, controller_dirs)))
32
+ return dest if File.exist?(dest) && File.read(dest, encoding: "utf-8") == json
33
+
34
+ FileUtils.mkdir_p(File.dirname(dest))
35
+ File.write(dest, json, encoding: "utf-8")
36
+ dest
37
+ end
38
+
39
+ def build(config, controller_dirs)
40
+ symbol_to_bundle = {}
41
+ bundle_own_symbols = Hash.new { |h, k| h[k] = Set.new }
42
+ bundle_files = Hash.new { |h, k| h[k] = [] }
43
+ bundle_dependencies = Hash.new { |h, k| h[k] = Set.new }
44
+
45
+ controller_dirs.each do |ctrl|
46
+ bundle_name = ctrl[:bundle_name]
47
+ files = js_files_in_controller(ctrl[:path], config)
48
+ bundle_files[bundle_name].concat(files)
49
+ isolated = config.isolated_app_dirs.include?(ctrl[:name])
50
+
51
+ files.each do |file_path|
52
+ extract(file_path, :extract_definitions).each do |symbol|
53
+ next unless symbol.match?(/\A[A-Z][A-Za-z0-9_]*\z/)
54
+
55
+ # Keep first writer to ensure deterministic behavior if a symbol is duplicated.
56
+ # Isolated dirs never register into the shared symbol index (see
57
+ # Generator#build_controller_context for the full rationale).
58
+ symbol_to_bundle[symbol] ||= bundle_name unless isolated
59
+ bundle_own_symbols[bundle_name] << symbol
60
+ end
61
+ end
62
+ end
63
+
64
+ bundle_files.each do |bundle_name, files|
65
+ own_symbols = bundle_own_symbols[bundle_name]
66
+
67
+ files.each do |file_path|
68
+ extract(file_path, :extract_usages).each do |symbol|
69
+ # A symbol the bundle defines itself is always satisfied locally —
70
+ # never attribute it to another bundle just because that symbol
71
+ # name happens to collide with something defined elsewhere.
72
+ next if own_symbols.include?(symbol)
73
+
74
+ dep_bundle = symbol_to_bundle[symbol]
75
+ next unless dep_bundle && dep_bundle != bundle_name
76
+
77
+ bundle_dependencies[bundle_name] << dep_bundle
78
+ end
79
+ end
80
+ end
81
+
82
+ { symbol_to_bundle: symbol_to_bundle, bundle_dependencies: bundle_dependencies }
83
+ end
84
+
85
+ def load_fresh(config, controller_dirs)
86
+ data = JSON.parse(File.read(path(config), encoding: "utf-8"))
87
+ return unless data["fingerprint"] == fingerprint(config, controller_dirs)
88
+
89
+ bundle_dependencies = Hash.new { |h, k| h[k] = Set.new }
90
+ data["bundle_dependencies"].each { |bundle, deps| bundle_dependencies[bundle] = deps.to_set }
91
+ { symbol_to_bundle: data["symbol_to_bundle"], bundle_dependencies: bundle_dependencies }
92
+ rescue StandardError
93
+ nil
94
+ end
95
+
96
+ def serialize(config, controller_dirs, maps)
97
+ {
98
+ "fingerprint" => fingerprint(config, controller_dirs),
99
+ "symbol_to_bundle" => maps[:symbol_to_bundle],
100
+ "bundle_dependencies" => maps[:bundle_dependencies].transform_values { |deps| deps.to_a.sort }
101
+ }
102
+ end
103
+
104
+ # Digest of every input the maps depend on. File paths are relative to ux_root so
105
+ # a relocated release dir stays valid; contents (not mtimes) so a checkout that
106
+ # rewrites timestamps cannot make a stale sidecar look fresh or a fresh one stale.
107
+ def fingerprint(config, controller_dirs)
108
+ digest = Digest::SHA256.new
109
+ digest << JSON.generate([VERSION, config.extensions, config.isolated_app_dirs])
110
+ controller_dirs.each do |ctrl|
111
+ digest << "\0" << ctrl[:bundle_name]
112
+ js_files_in_controller(ctrl[:path], config).each do |file|
113
+ digest << "\0" << file.delete_prefix(config.abs_ux_root) << "\0" << File.binread(file)
114
+ rescue SystemCallError
115
+ digest << "\0unreadable"
116
+ end
117
+ end
118
+ digest.hexdigest
119
+ end
120
+
121
+ def js_files_in_controller(dir, config)
122
+ return [] unless Dir.exist?(dir)
123
+
124
+ Dir.glob(File.join(dir, "**", config.extensions_glob))
125
+ .reject { |f| File.directory?(f) }
126
+ .sort
127
+ end
128
+
129
+ def extract(file_path, method)
130
+ content = File.read(file_path, encoding: "utf-8")
131
+ SymbolExtractor.public_send(method, content, file_path: file_path)
132
+ rescue Errno::ENOENT, Errno::EACCES, Encoding::InvalidByteSequenceError
133
+ []
134
+ end
135
+ end
136
+ end
@@ -21,9 +21,18 @@ module ReactManifest
21
21
  # Keeping generated files out of output_dir root avoids clutter.
22
22
  attr_accessor :manifest_subdir
23
23
 
24
+ # When true (default), the gem ensures the generated manifest dir is gitignored
25
+ # (adds the entry on dev boot if missing). Set false to manage .gitignore yourself.
26
+ attr_accessor :manage_gitignore
27
+
24
28
  # Bundle name for auto-generated shared bundle (all non-app/ dirs)
25
29
  attr_accessor :shared_bundle
26
30
 
31
+ # When true (default), a component defined under app_dir but used by any other
32
+ # bundle is emitted into the shared bundle (loaded once per page) instead of
33
+ # being inlined into each consumer. Set false to restore legacy inlining.
34
+ attr_accessor :auto_shared
35
+
27
36
  # Bundles always prepended by react_bundle_tag (e.g. ["ux_main"])
28
37
  attr_accessor :always_include
29
38
 
@@ -101,7 +110,9 @@ module ReactManifest
101
110
  @app_dir = "app"
102
111
  @output_dir = "app/assets/javascripts"
103
112
  @manifest_subdir = "ux_manifests"
113
+ @manage_gitignore = true
104
114
  @shared_bundle = "ux_shared"
115
+ @auto_shared = true
105
116
  @always_include = []
106
117
  @ignore = []
107
118
  @isolated_app_dirs = []
@@ -119,6 +130,10 @@ module ReactManifest
119
130
  !!@dry_run
120
131
  end
121
132
 
133
+ def auto_shared?
134
+ !!@auto_shared
135
+ end
136
+
122
137
  def verbose?
123
138
  !!@verbose
124
139
  end
@@ -127,6 +142,10 @@ module ReactManifest
127
142
  !!@stdout_logging
128
143
  end
129
144
 
145
+ def manage_gitignore?
146
+ !!@manage_gitignore
147
+ end
148
+
130
149
  # Glob fragment used by Dir.glob, e.g. "*.{js,jsx}" or "*.{js,jsx,ts,tsx}"
131
150
  def extensions_glob
132
151
  "*.{#{extensions.join(',')}}"