You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
Copy file name to clipboardExpand all lines: CHANGELOG.md
+1Lines changed: 1 addition & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -26,6 +26,7 @@ After a release, run `/update-changelog` in Claude Code to analyze commits, writ
26
26
27
27
#### Added
28
28
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).
29
30
- **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).
30
31
- **`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).
Copy file name to clipboardExpand all lines: docs/oss/building-features/caching.md
+90Lines changed: 90 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -211,6 +211,96 @@ case you have, React on Rails handles it.
211
211
212
212
---
213
213
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
`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
+
classPost < ApplicationRecord
253
+
includeReactOnRailsPro::Cache::Revalidates
254
+
255
+
revalidates_react_cache # default tag: record.cache_key, e.g. "posts/42"
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 |
|`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
+
214
304
## Cache Warming
215
305
216
306
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.
Copy file name to clipboardExpand all lines: docs/oss/deployment/docker-deployment.md
+9Lines changed: 9 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -388,6 +388,15 @@ cpflow deploy-image -a myapp # deploy the pushed image to Control Plane
388
388
- **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).
389
389
- **Secrets**: Use Control Plane's built-in secrets management (`cpln://secret/...`) instead of environment variables for sensitive values.
390
390
- **One-off tasks**: Run migrations and other one-off commands via `cpflow run -a myapp -- bundle exec rails db:migrate`.
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
+
103
125
## Legacy Repos
104
126
105
127
Version-specific demos, `test-*` repos, generator snapshots, and older tutorial
Copy file name to clipboardExpand all lines: docs/pro/fragment-caching.md
+31Lines changed: 31 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -41,6 +41,37 @@ end %>
41
41
42
42
A `cached_react_component_hash` variant is also available for cases where you need to extract metadata (like `<title>`) from the rendered output.
43
43
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
+
classPost < ApplicationRecord
65
+
includeReactOnRailsPro::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
+
44
75
## Cache Warming
45
76
46
77
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