Skip to content

Commit ba57cc6

Browse files
committed
Merge remote-tracking branch 'origin/main' into jg/3872-userailsform-v1
* origin/main: Add declarative cache_tags revalidation to Pro fragment caching (revalidateTag analog) (#3964) Verify and document CSP-nonce propagation for streamed RSC under a strict no-unsafe-inline policy (#3934) Docs: document Control Plane cost posture for demos (#3998) # Conflicts: # docs/.llms-exclusions
2 parents 5537fa7 + 848e23b commit ba57cc6

35 files changed

Lines changed: 3115 additions & 197 deletions

.lychee.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ exclude = [
135135
# ============================================================================
136136
# EXTERNAL DOC PAGES WITH CI CONNECTIVITY FAILURES
137137
# ============================================================================
138+
'^https://vite\.dev(/.*)?$', # Connection failed from CI
138139
'^https://tanstack\.com/query/latest/docs/framework/react/guides/ssr$', # Connection failed from CI
139140

140141
# ============================================================================

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ After a release, run `/update-changelog` in Claude Code to analyze commits, writ
2626

2727
#### Added
2828

29+
- **[Pro]** **Tag-based cache revalidation (a Next.js `revalidateTag` analog)**: The fragment-caching helpers (`cached_react_component`, `cached_react_component_hash`, `cached_stream_react_component`, `cached_async_react_component`) now accept an optional `cache_tags:` option (String, Proc, any object responding to `cache_key` such as an ActiveRecord model, or an Array of any mix), and the new `ReactOnRailsPro.revalidate_tag(tag)` / `revalidate_tags(*tags)` API deletes every cached entry registered under a tag via a `Rails.cache`-backed tag->key index. A new `ReactOnRailsPro::Cache::Revalidates` ActiveRecord concern (`revalidates_react_cache`) drives revalidation from `after_commit`, so the model that owns the data also owns cache invalidation (and composes with `touch:`). Revalidation is best-effort with correctness bounded by `expires_in` (a development-mode warning fires when `cache_tags:` is used without it); index growth is bounded by the new `config.cache_tag_index_expires_in` (default 7 days) and `config.cache_tag_index_max_keys` (default 5,000) settings. Existing `cache_key:`-only behavior is unchanged. Closes [Issue 3871](https://github.com/shakacode/react_on_rails/issues/3871). [PR 3964](https://github.com/shakacode/react_on_rails/pull/3964) by [justin808](https://github.com/justin808).
2930
- **React 19 root error callbacks**: `ReactOnRails.setOptions({ rootErrorHandlers: { onRecoverableError, onCaughtError, onUncaughtError } })` registers React's root error callbacks globally; React on Rails applies them to every `hydrateRoot`/`createRoot` call it makes and invokes them with an extra context argument whose `componentName` and `domNodeId` fields are optional. In development, recoverable hydration errors now log an actionable React on Rails message (component name, dom id, component stack, and a link to the new [Debugging Hydration Mismatches guide](https://reactonrails.com/docs/building-features/debugging-hydration-mismatches)) alongside React's default error reporting, which stays intact so window-'error'-based tooling keeps working. Partial `rootErrorHandlers` updates merge per key, so registering one callback later does not drop the others. On React <19 (and <18 for `onRecoverableError`), React on Rails retains registrations for future upgrades, but the current runtime cannot invoke unsupported callbacks and logs a one-time console warning. On React on Rails Pro RSC/streaming hydration paths, user callbacks chain with (never replace) Pro's internal recoverable-error handler. Addresses [Issue 3892](https://github.com/shakacode/react_on_rails/issues/3892). [PR 3933](https://github.com/shakacode/react_on_rails/pull/3933) by [justin808](https://github.com/justin808).
3031
- **`useRailsForm` hook + `render_model_errors` controller concern (an Inertia `useForm`-style bridge to Rails controllers)**: New React hook `useRailsForm` (importable from `react-on-rails/useRailsForm`) makes posting a React form to a plain Rails controller turnkey: `data`/`setData`, per-field `errors`, `processing`, `wasSuccessful`, submit verbs (`post`/`put`/`patch`/`delete`/`submit`), `reset`/`clearErrors`/`setError`, automatic CSRF attachment from the Rails csrf-token meta tag, JSON request/response handling, and mapping of `422` + `{ errors: { field: ["message"] } }` responses onto per-field error state. Success results surface a `redirectTo` target (followed-redirect URL or JSON `redirect_to` hint) without navigating, forward-compatible with the client-routing work in [Issue 3873](https://github.com/shakacode/react_on_rails/issues/3873). The gem side adds the opt-in `ReactOnRails::Controller::FormResponders` concern whose `render_model_errors(record)` renders ActiveModel errors in exactly that shape, so validations stay in the model with no API layer and no client-side duplication. Includes a new [Forms and Mutations](docs/oss/building-features/forms.md) docs page (with an Inertia `useForm` mapping table and a Server Functions [Issue 3867](https://github.com/shakacode/react_on_rails/issues/3867) cross-link) and a runnable dummy-app example (`/rails_form`). v1 is fetch-only; `transform`, `recentlySuccessful`, and file-upload `progress` are deferred. Closes [Issue 3872](https://github.com/shakacode/react_on_rails/issues/3872). [PR 3942](https://github.com/shakacode/react_on_rails/pull/3942) by [justin808](https://github.com/justin808).
3132

docs/.llms-exclusions

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,7 @@ misc/updating-dependencies
2222
misc/credits
2323
misc/articles
2424
misc/tips
25+
26+
# Older release-note detail pages remain linked from the release-notes index.
27+
# Excluded until llms-full is split so current product/API docs stay under the cap.
28+
upgrading/release-notes/16.1.0

docs/oss/building-features/caching.md

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,96 @@ case you have, React on Rails handles it.
211211

212212
---
213213

214+
## Tag-Based Revalidation
215+
216+
Cache keys answer "is this entry still current?" at read time. Tags answer a different question: "delete everything that depends on this data, right now." With `cache_tags:`, you attach declarative invalidation handles to fragment-cached components and bust them all with one call — the React on Rails Pro analog of Next.js `revalidateTag`.
217+
218+
```erb
219+
<%= cached_react_component("PostShow",
220+
cache_key: [@post, I18n.locale],
221+
cache_tags: [@post, @post.author],
222+
cache_options: { expires_in: 12.hours }) do
223+
{ post: @post.to_props }
224+
end %>
225+
```
226+
227+
```ruby
228+
# Anywhere in Ruby — controller, job, service object, console:
229+
ReactOnRailsPro.revalidate_tag(post) # => number of cache entries deleted
230+
ReactOnRailsPro.revalidate_tags(post, post.author)
231+
```
232+
233+
`cache_tags:` is accepted by all four cached helpers — `cached_react_component`, `cached_react_component_hash`, `cached_stream_react_component`, and `cached_async_react_component` — and is purely additive: `cache_key:` semantics are unchanged, and `cache_key:` is still required.
234+
235+
### Tag forms and normalization
236+
237+
A tag can be:
238+
239+
- a **String** — passed through unchanged (`"post:42"`)
240+
- a **Symbol or Numeric** — stringified via `.to_s` (`:featured` -> `"featured"`, `42` -> `"42"`)
241+
- an object responding to **`#cache_key`** (any ActiveRecord model) — ActiveRecord-style records normalize to the stable identity `posts/42` (equal to the version-less `record.cache_key`, and stable even when `cache_versioning` is disabled), so the tag stays valid as the record changes; other objects pass their `#cache_key` through. Objects with both `model_name` and `id` always resolve to `collection/id`; pass an explicit String tag if you want a different key.
242+
- a **Proc** (arity 0) returning any accepted form
243+
- an **Array** of any mix of the above
244+
245+
Tags that normalize to blank raise `ReactOnRailsPro::Error`. Tag coarsely (`post:42`, `tenant:7`, `posts:index`) — never per-user or per-request; see the index bounds below.
246+
247+
### Revalidating from the model layer
248+
249+
Include the `Revalidates` concern so the model that owns the data also owns cache invalidation. It runs in `after_commit`, so it never fires for a rolled-back transaction and fires only after the new data is visible to the re-rendering request:
250+
251+
```ruby
252+
class Post < ApplicationRecord
253+
include ReactOnRailsPro::Cache::Revalidates
254+
255+
revalidates_react_cache # default tag: record.cache_key, e.g. "posts/42"
256+
# or custom / additional tags:
257+
# revalidates_react_cache { |post| ["post:#{post.id}", "author:#{post.author_id}"] }
258+
end
259+
```
260+
261+
This covers create/update/destroy and `touch` — so `belongs_to ..., touch: true` composes for free: touching the parent fires the parent's revalidation. The standard Rails callback caveat applies: `update_column`, `update_all`, `delete_all`, and other callback-skipping writes do not trigger revalidation; call `ReactOnRailsPro.revalidate_tags(record)` yourself after such writes.
262+
263+
One more caveat for custom tag blocks: the block runs in `after_commit` and sees only the record's **new** values. If a custom tag derives from a mutable attribute (e.g. `"author:#{post.author_id}"` and a post moves to a different author), the old grouping's entries are not revalidated — they expire via `expires_in`. Prefer tags derived from the record's own identity, or revalidate the old grouping explicitly (`previous_changes` in `after_commit` has the prior value).
264+
265+
### How it works, and the contract
266+
267+
On every tagged cache write, the final cache key is appended to a per-tag index entry in `Rails.cache` (keyed by a SHA-256 digest under `rorp:tag:v1:` so long tag names and whitespace do not violate cache-store key limits). `revalidate_tag` reads the index, deletes the recorded entries with `delete_multi`, then deletes the index entry. A missing index (never-written tag, evicted entry, `:null_store`) means "nothing to revalidate" — never an error.
268+
269+
**Tag revalidation is best-effort; correctness is bounded by `expires_in`.** `ActiveSupport::Cache` has no atomic set-append, so the index append is a read-modify-write: two processes caching different entries under the same tag at the same moment can race, and one entry can be lost _from the index_ (the cached data itself is never lost). A lost index entry simply survives `revalidate_tag` and expires via its own `expires_in`. The same applies when the index entry itself is LRU-evicted. Therefore:
270+
271+
- **Always set `expires_in` (or `expires_at`) on tagged entries.** It is the upper bound on how long a missed invalidation can serve stale HTML. In development, React on Rails Pro logs a warning when `cache_tags:` is used without an expiry.
272+
- **Use a shared cache store in production** — Redis or Memcached. With `:memory_store` the index is per-process, so `revalidate_tag` in one process cannot see entries written by another; with `:null_store` tags are inert.
273+
- **Keep cache deletion failures bounded by expiry.** `revalidate_tag` clears the tag index before deleting the indexed entries to reduce re-registration races. If the cache store raises during deletion, any surviving entries are orphaned from that tag and only expire via their own `expires_in`.
274+
- **Custom cache stores must honor `namespace: nil` in `delete_multi` and `delete`.** The tag index records the fully namespaced logical keys that Rails wrote, then suppresses the store default namespace at delete time. Stores that ignore `options[:namespace]` can silently miss tag-revalidation deletes.
275+
276+
Two config knobs bound the index (defaults shown):
277+
278+
```ruby
279+
ReactOnRailsPro.configure do |config|
280+
config.cache_tag_index_expires_in = 7.days # index TTL ceiling for entries without expires_in
281+
config.cache_tag_index_max_keys = 5_000 # keys recorded per tag; oldest dropped beyond this
282+
end
283+
```
284+
285+
When a tagged entry has `expires_in`, the index entry's TTL automatically covers it (plus slack). When a tag exceeds the per-tag key cap, the oldest keys are dropped with a logged warning — those entries fall back to plain TTL expiration.
286+
287+
Note that tags solve **data**-driven invalidation only. Deploy invalidation is already handled by the server-bundle digest in the cache key (see [Cache Warming](#cache-warming)) — a deploy cold-starts prerendered fragment caches regardless of tags.
288+
289+
### Next.js mapping
290+
291+
| Next.js 16 (Cache Components) | React on Rails Pro |
292+
| --------------------------------------------------- | --------------------------------------------------------------------------------------- |
293+
| `'use cache'` on a function/component | `cached_react_component` / `cached_react_component_hash` / streaming and async variants |
294+
| `cacheTag('post-42')` inside the cached scope | `cache_tags: ["post:42"]` helper option |
295+
| `revalidateTag('post-42')` in a Server Action | `ReactOnRailsPro.revalidate_tag("post:42")` — anywhere in Ruby, incl. `after_commit` |
296+
| Manually wiring `revalidateTag` into every mutation | `include ReactOnRailsPro::Cache::Revalidates` — invalidation rides the AR transaction |
297+
| `cacheLife` profiles | `cache_options: { expires_in: ... }` |
298+
| `revalidateTag(tag, 'max')` / SWR profiles | Not yet supported (stale-while-revalidate is a possible future addition) |
299+
300+
Like Next.js's default `revalidateTag`, revalidation deletes: the next request re-renders and re-registers. There is no background refresh.
301+
302+
---
303+
214304
## Cache Warming
215305

216306
Fragment cache keys include the server bundle digest, which means every deploy creates new cache keys. This is correct — rendered output must match the current bundle — but it means every deploy starts with a cold cache. Under live traffic, this creates a synchronized storm of cache misses: every user request triggers full SSR, database queries for props assembly, and JS evaluation simultaneously.

docs/oss/deployment/docker-deployment.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,15 @@ cpflow deploy-image -a myapp # deploy the pushed image to Control Plane
388388
- **GVC environment variables**: Set shared environment variables at the GVC level so all workloads inherit them. See the [Control Plane Flow guide to secrets and ENV values](https://github.com/shakacode/control-plane-flow/blob/main/docs/secrets-and-env-values.md).
389389
- **Secrets**: Use Control Plane's built-in secrets management (`cpln://secret/...`) instead of environment variables for sensitive values.
390390
- **One-off tasks**: Run migrations and other one-off commands via `cpflow run -a myapp -- bundle exec rails db:migrate`.
391+
- **Public demo and starter staging apps**: Keep `type: standard` workloads
392+
with `minScale: 1`, the autoscaling metric disabled, and `capacityAI: true`.
393+
Avoid CPU Utilization autoscaling pinned to `minScale: 1` / `maxScale: 1`,
394+
because that prevents Capacity AI from right-sizing the warm workload. When
395+
the autoscaling metric is disabled, do not rely on `maxScale` for bursts; use
396+
a compatible autoscaling metric for that separate scaling posture. Use
397+
`serverless` scale-to-zero only as an explicit first-deploy choice or planned
398+
delete/recreate migration. See the
399+
[Control Plane Cost Posture](../getting-started/examples-and-references.md#control-plane-cost-posture).
391400
- **Multiple locations**: Control Plane supports multi-region deployment. Add locations to your GVC to deploy globally.
392401

393402
## Node Renderer in containers

docs/oss/getting-started/examples-and-references.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,28 @@ For detailed proof criteria and migration contribution guidance, see
100100
legacy [shakacode/react-webpack-rails-tutorial](https://github.com/shakacode/react-webpack-rails-tutorial)
101101
source.
102102

103+
## Control Plane Cost Posture
104+
105+
For public demo and starter staging deployments on Control Plane, keep the app
106+
workload as `type: standard` with `minScale: 1`, set its autoscaling metric to
107+
`disabled`, and enable `capacityAI: true` so Control Plane can right-size idle
108+
capacity while the demo keeps one warm replica. With the autoscaling metric set
109+
to `disabled`, treat replica count as fixed; `maxScale` is not a burst-scaling
110+
lever in this posture. If a demo must absorb traffic bursts, choose a compatible
111+
autoscaling metric deliberately and set a tested `maxScale` ceiling for that
112+
separate scaling posture.
113+
Avoid `CPU Utilization` autoscaling with `minScale: 1` / `maxScale: 1` for
114+
these small staging apps because that combination prevents Capacity AI from
115+
right-sizing the warm workload.
116+
117+
This is not the same as scale-to-zero: steady RAM usage and background work can
118+
still drive cost, and shared Postgres should usually stay manually sized. If a
119+
demo explicitly needs true idle scale-to-zero, create a separate `serverless`
120+
workload before first deploy or plan a delete/recreate migration because Control
121+
Plane will not change an existing `standard` workload to `serverless` in place.
122+
The reusable guidance lives in
123+
[Control Plane Flow: Enable Capacity AI for Demo and Starter Staging Apps](https://github.com/shakacode/control-plane-flow/blob/main/docs/tips.md#enable-capacity-ai-for-demo-and-starter-staging-apps).
124+
103125
## Legacy Repos
104126

105127
Version-specific demos, `test-*` repos, generator snapshots, and older tutorial

docs/pro/fragment-caching.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,37 @@ end %>
4141

4242
A `cached_react_component_hash` variant is also available for cases where you need to extract metadata (like `<title>`) from the rendered output.
4343

44+
## Tag-Based Revalidation
45+
46+
Cache keys handle "is this entry still current?" at read time. For the write side — "this record changed, bust every cached component that depends on it" — tag the entries and revalidate by tag (the React on Rails Pro analog of Next.js `revalidateTag`):
47+
48+
```erb
49+
<%= cached_react_component("PostShow",
50+
cache_key: [@post, I18n.locale],
51+
cache_tags: [@post],
52+
cache_options: { expires_in: 12.hours }) do
53+
{ post: @post.to_props }
54+
end %>
55+
```
56+
57+
```ruby
58+
ReactOnRailsPro.revalidate_tag(post) # deletes every entry tagged with post.cache_key
59+
```
60+
61+
Or let the model own its invalidation via `after_commit`:
62+
63+
```ruby
64+
class Post < ApplicationRecord
65+
include ReactOnRailsPro::Cache::Revalidates
66+
67+
revalidates_react_cache # default tag: record.cache_key, e.g. "posts/42"
68+
end
69+
```
70+
71+
Tag revalidation is best-effort and bounded by `expires_in` — always set it on tagged entries, and use a shared cache store (Redis/Memcached) in production. If a cache store raises while deleting tagged entries, the tag index may already be cleared; any surviving entries can no longer be found by that tag and will only drain through their own expiry. See the [Tag-Based Revalidation section](../oss/building-features/caching.md#tag-based-revalidation) of the caching guide for the full contract, tag normalization rules, index configuration, and the Next.js `revalidateTag` mapping.
72+
73+
ActiveRecord-style tag objects normalize to `collection/id` (for example `posts/42`) before they are indexed. Pass an explicit String tag if a value object exposes `model_name` and `id` but should use a custom key.
74+
4475
## Cache Warming
4576

4677
Every deploy creates new cache keys for prerendered components (because the server bundle digest is included in the cache key when `prerender: true`). For client-only cached components, version your own cache key to invalidate on deploy. To avoid a storm of cold-cache misses under live traffic, warm your highest-traffic pages in background jobs immediately after deploy.

0 commit comments

Comments
 (0)