Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
cbae9b1
Add declarative cache_tags revalidation to Pro fragment caching
justin808 Jun 12, 2026
13e99ea
Regenerate llms-full.txt for the tag revalidation docs
justin808 Jun 12, 2026
27ab391
Address codex review: namespace-aware deletes and expires_at index TTL
justin808 Jun 12, 2026
4ab6812
Address codex review: stable AR tag identity under cache_versioning=f…
justin808 Jun 12, 2026
8fedcfd
Address codex review: record logical cache names, not store-encoded keys
justin808 Jun 12, 2026
fded78a
Address codex review: delete_multi fallback and mutable-tag caveat docs
justin808 Jun 12, 2026
d738c2a
Fix changelog PR link to actual PR number 3964
justin808 Jun 12, 2026
eedfec9
Make revalidates_react_cache idempotent across repeated calls
justin808 Jun 12, 2026
80a71de
Regenerate llms-full.txt after rebase onto post-rc.3 main
justin808 Jun 12, 2026
e052714
Address current-head review findings on PR #3964
justin808 Jun 13, 2026
da1cf97
Address cache tag config review edges
justin808 Jun 13, 2026
7de6388
Regenerate llms-full below cap
justin808 Jun 13, 2026
67fb197
Fix cache tag validation before writes
justin808 Jun 13, 2026
417e4bc
Preserve cache-key tags during revalidation
justin808 Jun 14, 2026
87d751f
Reject unpersisted cache tag records
justin808 Jun 14, 2026
c9ca5af
Accept Rails durations for cache tag TTL
justin808 Jun 14, 2026
1411d5b
Address cache tag revalidation review follow-ups
justin808 Jun 14, 2026
2b25dd4
Keep llms-full under split threshold
justin808 Jun 14, 2026
a0c1679
Address cache tag revalidation review blockers
justin808 Jun 14, 2026
045fe79
Regenerate llms-full-pro after rebase
justin808 Jun 14, 2026
9505821
Align cache tag expiry semantics
justin808 Jun 14, 2026
9f3166b
Clarify cache tag edge cases
justin808 Jun 14, 2026
6f35f4a
Harden cache tag expiry and index keys
justin808 Jun 14, 2026
4a4af77
Merge remote-tracking branch 'origin/main' into jg/3871-cache-tags-re…
justin808 Jun 14, 2026
dd87dd1
Avoid caching expired cache tag writes
justin808 Jun 14, 2026
67f880f
Recompute async cache write expiry at completion
justin808 Jun 14, 2026
3eea87e
Harden cache tag review fixes
justin808 Jun 14, 2026
08c161d
Honor legacy expires_in with unsupported expires_at
justin808 Jun 14, 2026
7513fe4
Harden cache tag revalidation review edge cases
justin808 Jun 15, 2026
b85673c
Regenerate llms bundle after changelog move
justin808 Jun 15, 2026
14ead8d
Merge main for cache tag revalidation closeout
justin808 Jun 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .lychee.toml
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ exclude = [
# ============================================================================
# EXTERNAL DOC PAGES WITH CI CONNECTIVITY FAILURES
# ============================================================================
'^https://vite\.dev(/.*)?$', # Connection failed from CI
'^https://tanstack\.com/query/latest/docs/framework/react/guides/ssr$', # Connection failed from CI

# ============================================================================
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ After a release, run `/update-changelog` in Claude Code to analyze commits, writ

#### Added

- **[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).
- **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).

#### Changed
Expand Down
19 changes: 17 additions & 2 deletions docs/.llms-exclusions
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Docs intentionally excluded from the generated llms-full.txt
# (redirect stubs with no real content). One doc ID per line.
# Docs intentionally excluded from the generated llms-full.txt.
# Prefer redirect stubs with no real content. Full docs may be temporarily
# excluded only to stay below the hard split threshold until llms-full is split.
# One doc ID per line.
# Inline comments (after #) are allowed.
# Used by script/generate-llms-full.mjs.

Expand All @@ -11,3 +13,16 @@ misc/asset-pipeline

# URL-compatibility stub that redirects to pro/react-on-rails-pro
pro/home-pro

# Contributing/Resources pages (linked from introduction.md, not the sidebar).
# Excluded until llms-full is split so product/API docs remain under the cap.
misc/doctrine
misc/style
misc/updating-dependencies
misc/credits
misc/articles
misc/tips

# Older release-note detail pages remain linked from the release-notes index.
# Excluded until llms-full is split so current product/API docs stay under the cap.
upgrading/release-notes/16.1.0
90 changes: 90 additions & 0 deletions docs/oss/building-features/caching.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,96 @@ case you have, React on Rails handles it.

---

## Tag-Based Revalidation

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`.

```erb
<%= cached_react_component("PostShow",
cache_key: [@post, I18n.locale],
cache_tags: [@post, @post.author],
cache_options: { expires_in: 12.hours }) do
{ post: @post.to_props }
end %>
```

```ruby
# Anywhere in Ruby — controller, job, service object, console:
ReactOnRailsPro.revalidate_tag(post) # => number of cache entries deleted
ReactOnRailsPro.revalidate_tags(post, post.author)
```

`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.

### Tag forms and normalization

A tag can be:

- a **String** — passed through unchanged (`"post:42"`)
- a **Symbol or Numeric** — stringified via `.to_s` (`:featured` -> `"featured"`, `42` -> `"42"`)
- 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.
- a **Proc** (arity 0) returning any accepted form
- an **Array** of any mix of the above

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.

### Revalidating from the model layer

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:

```ruby
class Post < ApplicationRecord
include ReactOnRailsPro::Cache::Revalidates

revalidates_react_cache # default tag: record.cache_key, e.g. "posts/42"
# or custom / additional tags:
# revalidates_react_cache { |post| ["post:#{post.id}", "author:#{post.author_id}"] }
end
```

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.

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).

### How it works, and the contract

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.

**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:

- **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.
- **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.
- **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`.
- **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.

Two config knobs bound the index (defaults shown):

```ruby
ReactOnRailsPro.configure do |config|
config.cache_tag_index_expires_in = 7.days # index TTL ceiling for entries without expires_in
config.cache_tag_index_max_keys = 5_000 # keys recorded per tag; oldest dropped beyond this
end
```

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.

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.

### Next.js mapping

| Next.js 16 (Cache Components) | React on Rails Pro |
| --------------------------------------------------- | --------------------------------------------------------------------------------------- |
| `'use cache'` on a function/component | `cached_react_component` / `cached_react_component_hash` / streaming and async variants |
| `cacheTag('post-42')` inside the cached scope | `cache_tags: ["post:42"]` helper option |
| `revalidateTag('post-42')` in a Server Action | `ReactOnRailsPro.revalidate_tag("post:42")` — anywhere in Ruby, incl. `after_commit` |
| Manually wiring `revalidateTag` into every mutation | `include ReactOnRailsPro::Cache::Revalidates` — invalidation rides the AR transaction |
| `cacheLife` profiles | `cache_options: { expires_in: ... }` |
| `revalidateTag(tag, 'max')` / SWR profiles | Not yet supported (stale-while-revalidate is a possible future addition) |

Like Next.js's default `revalidateTag`, revalidation deletes: the next request re-renders and re-registers. There is no background refresh.

---

## Cache Warming

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.
Expand Down
31 changes: 31 additions & 0 deletions docs/pro/fragment-caching.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,37 @@ end %>

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

## Tag-Based Revalidation

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`):

```erb
<%= cached_react_component("PostShow",
cache_key: [@post, I18n.locale],
cache_tags: [@post],
cache_options: { expires_in: 12.hours }) do
{ post: @post.to_props }
end %>
```

```ruby
ReactOnRailsPro.revalidate_tag(post) # deletes every entry tagged with post.cache_key
```

Or let the model own its invalidation via `after_commit`:

```ruby
class Post < ApplicationRecord
include ReactOnRailsPro::Cache::Revalidates

revalidates_react_cache # default tag: record.cache_key, e.g. "posts/42"
end
```

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.

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.

## Cache Warming

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.
Expand Down
31 changes: 31 additions & 0 deletions llms-full-pro.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3875,6 +3875,37 @@ end %>

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

## Tag-Based Revalidation

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`):

```erb
<%= cached_react_component("PostShow",
cache_key: [@post, I18n.locale],
cache_tags: [@post],
cache_options: { expires_in: 12.hours }) do
{ post: @post.to_props }
end %>
```

```ruby
ReactOnRailsPro.revalidate_tag(post) # deletes every entry tagged with post.cache_key
```

Or let the model own its invalidation via `after_commit`:

```ruby
class Post < ApplicationRecord
include ReactOnRailsPro::Cache::Revalidates

revalidates_react_cache # default tag: record.cache_key, e.g. "posts/42"
end
```

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.

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.

## Cache Warming

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.
Expand Down
Loading
Loading