activeadmin-react 0.1.0.alpha1 → 0.3.0

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: 92cba0113794ff8883e09e5bf1cfad4b498c705bf97fdabcf2c461c2d320e04c
4
- data.tar.gz: 54dbdaed377bcd530612b4077ad9364b167b7a268438b09d8d038ff15d798052
3
+ metadata.gz: '0250683b92e883cd5386b556c1b0b3ca2db12e9d27e2fb6401360575a1de33f5'
4
+ data.tar.gz: 66d6cfd94df946bda32b5f8ef8821a2a15f1ecdc6c211777c2b141e558e0e26a
5
5
  SHA512:
6
- metadata.gz: 3aead5075fda511f7d0ce83ae644860eb2831432c93c99aeab3a68b5b8c07d513049237d1826db73b2bd79971d45845db449e4a113f09d874e8d709893ce95ee
7
- data.tar.gz: dff36285667c02f65f3a52fe0f24ef0f52a5428e8be2640dd142f837b4cb42b4eeb799a3f7a18e119ea4a49c0c3c9ccda73bd16054b1ad6f5d18f157a1b2c87c
6
+ metadata.gz: b01d4e7a51fe68600e13bfc8c994d8e64b4efcfa68e5fb7e71ff3b887a39294d6e2f16e4c62fa1af9e9da5e2af132c600475a1e1c53b643c219f6b19920e19f0
7
+ data.tar.gz: fac7bcd73de4ded661e73c07eeb088d6058227ff79eaffe83c62a97531ecde687468ce0004dd6f9a81e6246cc41d27eee4279b9d509f88a23bdb919b11f8544e
data/CHANGELOG.md CHANGED
@@ -4,6 +4,45 @@
4
4
 
5
5
  All notable changes to ActiveAdmin React are recorded here. During ordinary pre-1.0 development, PATCH releases contain fixes and small compatible improvements; MINOR releases may contain new capabilities, meaningful API evolution, and documented breaking changes.
6
6
 
7
+ ## Unreleased
8
+
9
+ ## 0.3.0 — 2026-09-19
10
+
11
+ This release advances the MINOR line because it adds a public TypeScript contract for
12
+ the packaged JavaScript API without changing runtime behavior.
13
+
14
+ ### Added
15
+
16
+ - Packaged `index.d.ts` declarations now cover every public registry, runtime,
17
+ protocol, Cable, resumable-subscription, and operation export.
18
+ - Strict independent-consumer verification now proves supported usage typechecks and
19
+ representative invalid usage fails without a host-owned ambient declaration.
20
+
21
+ ### Changed
22
+
23
+ - The minimum supported ActiveAdmin version is now `4.0.0.beta23`, which raises
24
+ the minimum supported Ruby version to 3.3.
25
+
26
+ ## 0.2.0 — 2026-09-19
27
+
28
+ First ordinary pre-1.0 release after the initial Rodeo dogfooding prerelease. This
29
+ release advances the MINOR line because it adds reusable Action Cable delivery and
30
+ resumable-subscription capabilities alongside compatibility and lifecycle fixes.
31
+
32
+ ### Added
33
+
34
+ - `subscribeResumable` now provides fixed resume, monotonic cursor, deduplication, protocol-error, and idempotent cleanup mechanics for application-owned Action Cable streams.
35
+ - `ActiveAdmin::React::Cable.broadcast` now provides an observable, payload-safe best-effort delivery boundary with an immutable `BroadcastResult`.
36
+
37
+ ### Changed
38
+
39
+ - Ordinary pre-1.0 releases now use unsuffixed `0.MINOR.PATCH` versions; prerelease suffixes are reserved for explicitly authorized major-release stabilization trains.
40
+
41
+ ### Fixed
42
+
43
+ - React islands now unmount before Turbo replaces rendered content and remount after `turbo:render`, including `422` form validation responses.
44
+ - Rails 8.1.3.1 integration and browser validation now constrain the test-only JSON dependency below 3 until Rails supports JSON 3 keyword arguments.
45
+
7
46
  ## 0.1.0.alpha1 — 2026-09-05
8
47
 
9
48
  First integrated prerelease for Rodeo dogfooding. Public Ruby and JavaScript contracts
data/README.md CHANGED
@@ -11,10 +11,10 @@ ActiveAdmin React keeps administrative pages Rails-first and server-rendered whi
11
11
  Add the gem to a Rails application that uses ActiveAdmin:
12
12
 
13
13
  ```ruby
14
- gem "activeadmin-react", "0.1.0.alpha1", require: "active_admin/react"
14
+ gem "activeadmin-react", "0.3.0", require: "active_admin/react"
15
15
  ```
16
16
 
17
- Then run `bundle install`. The gem requires Ruby 3.2 or newer, Rails 8.x, and ActiveAdmin `4.0.0.beta22` or newer within the 4.x line. The JavaScript runtime uses the React 18/19 `createRoot` API; the host supplies `react` and `react-dom` and remains responsible for compiling and serving browser assets.
17
+ Then run `bundle install`. The gem requires Ruby 3.3 or newer, Rails 8.x, and ActiveAdmin `4.0.0.beta23` or newer within the 4.x line. The JavaScript runtime uses the React 18/19 `createRoot` API; the host supplies `react` and `react-dom` and remains responsible for compiling and serving browser assets.
18
18
 
19
19
  ## Render an island from Arbre
20
20
 
@@ -41,7 +41,7 @@ The mount owns the `data-react-component` and `data-react-props` attributes. Oth
41
41
 
42
42
  ## Register and start components
43
43
 
44
- The packaged JavaScript entrypoint is `app/javascript/active_admin/react/index.js`. Configure the host's Vite, esbuild, or equivalent resolver so `active_admin/react` points to that file inside the installed gem. For example, Vite can derive the gem root with `bundle show activeadmin-react`:
44
+ The packaged JavaScript entrypoint is `app/javascript/active_admin/react/index.js`. Its sibling `index.d.ts` describes every public registry, runtime, protocol, Cable, and operation export. Configure the host's Vite, esbuild, or equivalent resolver so `active_admin/react` points to that file inside the installed gem. For example, Vite can derive the gem root with `bundle show activeadmin-react`:
45
45
 
46
46
  ```js
47
47
  import { execFileSync } from "node:child_process"
@@ -61,6 +61,29 @@ export default defineConfig({
61
61
  })
62
62
  ```
63
63
 
64
+ TypeScript must resolve the same import to the containing directory so it discovers
65
+ `index.d.ts`; do not maintain a host-owned ambient declaration. For a vendored or
66
+ otherwise stable gem path, add a compiler path such as:
67
+
68
+ ```json
69
+ {
70
+ "compilerOptions": {
71
+ "baseUrl": ".",
72
+ "paths": {
73
+ "active_admin/react": [
74
+ "vendor/bundle/ruby/4.0.0/gems/activeadmin-react-0.3.0/app/javascript/active_admin/react"
75
+ ]
76
+ }
77
+ }
78
+ }
79
+ ```
80
+
81
+ Generate or update that path from `bundle show activeadmin-react` when the bundle
82
+ location changes. The declarations reference the official React types, so TypeScript
83
+ hosts provide versions of `@types/react` and `@types/react-dom` matching their React
84
+ runtime. See the [TypeScript consumer contract](docs/typescript.md) for the supported
85
+ types and an independent strict-mode verification pattern.
86
+
64
87
  Register every component before starting the runtime:
65
88
 
66
89
  ```js
@@ -71,7 +94,7 @@ registerComponent("OrdersTable", OrdersTable)
71
94
  start()
72
95
  ```
73
96
 
74
- `start()` mounts every `[data-react-component]` island, mounts newly rendered pages after `turbo:load`, and unmounts roots before Turbo caches the page. Repeated calls are safe. `stop()` removes the Turbo listeners and unmounts tracked roots. Duplicate component names, unknown components, and malformed JSON props fail loudly.
97
+ `start()` mounts every `[data-react-component]` island, mounts newly rendered pages after `turbo:load`, and unmounts roots before Turbo caches the page. It also unmounts before `turbo:before-render` and remounts after `turbo:render`, covering form validation responses that replace page content without a new Turbo visit. Repeated calls are safe. `stop()` removes the Turbo listeners and unmounts tracked roots. Duplicate component names, unknown components, and malformed JSON props fail loudly.
75
98
 
76
99
  The shipped modules use package-style relative imports intended for a JavaScript build tool. Copying the directory directly into an importmap or serving it to browsers without a resolver is not currently a supported integration path.
77
100
 
@@ -102,6 +125,58 @@ end
102
125
 
103
126
  `ActiveAdmin::React::Contributions.diagnostics` returns entries sorted by namespace, component name, and owner. An installer can query `ActiveAdmin::React::Contributions.registry.registered?("OrdersTable")` before registering inside a reload hook. `ActiveAdmin::React::Contributions.reset!` creates a fresh registry for test isolation. Metadata hashes, arrays, sets, and strings are recursively copied and frozen during registration, so later changes to caller-owned values cannot alter registered state and diagnostics cannot mutate it. Other metadata values must be immutable objects supplied by the contributor.
104
127
 
128
+ ## Generic resumable Action Cable streams
129
+
130
+ `subscribeResumable` owns only the common client transport mechanics for an application-owned, monotonically sequenced stream. The server still owns authorization, replay queries, and ordering replay before buffered live delivery:
131
+
132
+ ```js
133
+ import { subscribeResumable } from "active_admin/react"
134
+
135
+ const cursor = {
136
+ current: () => latestSequence,
137
+ advance: (sequence) => { latestSequence = sequence }
138
+ }
139
+
140
+ const subscription = subscribeResumable({
141
+ consumer,
142
+ channel: "AuditEventsChannel",
143
+ params: { audit_id: auditId },
144
+ cursor,
145
+ parse(raw) {
146
+ const event = parseAuditEvent(raw)
147
+ return { event, sequence: event.sequence }
148
+ },
149
+ onEvent: (event) => applyAuditEvent(event),
150
+ onStatus: (status, details) => reportConnection(status, details),
151
+ onProtocolError: (error, raw) => reportMalformedDelivery(error, raw)
152
+ })
153
+
154
+ return () => subscription.unsubscribe()
155
+ ```
156
+
157
+ Every connection performs the fixed `resume` action with `{ after_sequence: cursor.current() }`. Cursors and parsed sequences must be non-negative safe integers. Duplicate and stale sequences are ignored. A fresh event reaches `onEvent` before the cursor advances, so a failing handler leaves the delivery eligible for replay. Protocol errors throw when `onProtocolError` is omitted. Cleanup is idempotent and unsubscribes only this subscription; shared consumers are never disconnected.
158
+
159
+ The helper deliberately excludes domain reduction, terminal-state behavior, rendering, retry policy, configurable resume actions, reset/epoch semantics, authorization, and server replay implementation. See the [resumable subscription contract](docs/resumable-subscriptions.md).
160
+
161
+ ## Best-effort Action Cable delivery
162
+
163
+ Persist durable application truth before broadcasting its live projection. `ActiveAdmin::React::Cable.broadcast` contains transport failures and returns an immutable result instead of allowing Action Cable availability to change committed domain state:
164
+
165
+ ```ruby
166
+ result = ActiveAdmin::React::Cable.broadcast(
167
+ stream: operation.broadcast_key,
168
+ payload: event.envelope,
169
+ context: { workflow: "operation", event_id: event.id }
170
+ )
171
+
172
+ result.success? # true when Action Cable accepted the broadcast
173
+ result.error # the rescued transport error, or nil
174
+ ```
175
+
176
+ Failures emit `broadcast_failure.active_admin_react` through `ActiveSupport::Notifications` and write one Rails error log. Diagnostics contain the stream, caller-supplied context, and error, but never the payload. Keep context bounded and non-sensitive. Logger and notification-subscriber failures are themselves contained so live delivery cannot become authoritative through observability.
177
+
178
+ The helper does not provide persistence, transactions, jobs, retries, replay, or domain state. See the [Cable delivery contract](docs/cable-delivery.md) before adopting it.
179
+
105
180
  ## Asynchronous Action Cable operations
106
181
 
107
182
  Action Cable transports operation state; application jobs and services own the expensive work. Each event uses a server-owned operation identifier, idempotency key, and monotonic sequence:
data/RELEASES.md CHANGED
@@ -8,24 +8,33 @@ maturity determine when a release is ready.
8
8
 
9
9
  ## Pre-1.0 versions
10
10
 
11
- Development uses ordinary `0.MINOR.PATCH` versions, with explicit `0.MINOR.PATCH.alphaN`
12
- and `0.MINOR.PATCH.betaN` prereleases when authorized. The first integrated prerelease
13
- is `0.1.0.alpha1`. Prerelease numbers start at 1 and have no leading zeroes.
14
-
15
- - Increment PATCH for fixes and small backward-compatible improvements.
16
- - Increment MINOR for new capabilities, meaningful API evolution, and documented breaking
17
- changes while the public API remains unstable under Semantic Versioning's `0.y.z` rules.
11
+ Development uses ordinary `0.MINOR.PATCH` versions. The first integrated dogfooding
12
+ prerelease was `0.1.0.alpha1`; subsequent ordinary pre-1.0 development does not use
13
+ `alpha`, `beta`, or `rc` suffixes.
14
+
15
+ - Increment MINOR and reset PATCH to zero for new capabilities, meaningful API evolution,
16
+ and documented breaking changes while the public API remains unstable under Semantic
17
+ Versioning's `0.y.z` rules.
18
+ - Increment PATCH within the current MINOR line for fixes and small backward-compatible
19
+ improvements.
18
20
  - Keep Rodeo-specific business behavior outside the gem. Rodeo dogfooding supplies the
19
21
  primary evidence for whether generally useful contracts are ready to stabilize.
20
22
  - Track ActiveAdmin 4 closely and consider generally useful ActiveAdmin or Arbre fixes for
21
23
  upstream contribution instead of permanent private patches.
22
24
 
23
- ## Path to 1.0
25
+ ## Major-release stabilization
26
+
27
+ Reserve `alphaN`, `betaN`, and `rcN` suffixes for an explicitly authorized major-release
28
+ stabilization train. For the eventual path to `1.0.0`, begin that train only when Rodeo
29
+ dogfooding indicates that the Ruby API, JavaScript adapter protocol, security guidance,
30
+ packaging, and compatibility policy are ready to stabilize. Prerelease numbers start at
31
+ 1 and have no leading zeroes. Publish `1.0.0` only after the authorized stabilization
32
+ phases are complete and only release-blocking defects remain.
24
33
 
25
- Move to `1.0.0.rc1` only when Rodeo dogfooding indicates that the Ruby API, JavaScript
26
- adapter protocol, security guidance, packaging, and compatibility policy are ready to
27
- stabilize. Publish additional candidates as `1.0.0.rcN` when needed, then publish `1.0.0`
28
- after only release-blocking defects remain.
34
+ Authorization of a major-release train includes reviewed updates to the tag guard,
35
+ workflow trigger, and protected `release` environment. The current executable policy
36
+ admits ordinary `v0.MINOR.PATCH` tags and the previously authorized future
37
+ `v1.0.0.rcN` shape; it does not admit suffixed `0.x` tags.
29
38
 
30
39
  The stable release guarantees an Arbre-native mounting API, deterministic React lifecycle,
31
40
  documented React and ActiveAdmin compatibility, Action Cable-friendly asynchronous
@@ -37,8 +46,9 @@ public APIs.
37
46
  1. Merge the entire reviewed stack into `master`.
38
47
  2. Ensure CI is green at the exact release commit.
39
48
  3. Update `ActiveAdmin::React::VERSION` and release notes.
40
- 4. Tag that exact commit with the matching `v0.MINOR.PATCH`, optional `.alphaN` or
41
- `.betaN` suffix, or future `v1.0.0.rcN` tag. Numeric components have no leading zeroes.
49
+ 4. Tag an ordinary pre-1.0 release as exactly `v0.MINOR.PATCH`. Use `.alphaN`, `.betaN`,
50
+ or `.rcN` only for an explicitly authorized major-release stabilization train. Numeric
51
+ components and prerelease counters have no leading zeroes.
42
52
  5. Let GitHub Actions publish through RubyGems Trusted Publishing and the `release`
43
53
  environment.
44
54
  6. Verify the gem is installable and its provenance is visible on RubyGems.org.
@@ -0,0 +1,225 @@
1
+ // app/javascript/active_admin/react/index.d.ts
2
+
3
+ import type { ComponentType } from "react"
4
+ import type { Root } from "react-dom/client"
5
+
6
+ export type ComponentName = string | number
7
+ export type ComponentProps = Record<string, unknown>
8
+
9
+ export function registerComponent<Props = ComponentProps>(
10
+ name: ComponentName,
11
+ component: ComponentType<Props>
12
+ ): void
13
+ export function resolveComponent<Props = ComponentProps>(name: ComponentName): ComponentType<Props> | undefined
14
+ export function clearComponents(): void
15
+
16
+ export type MountRoot = Document | Element
17
+
18
+ export function mountElement(element: HTMLElement): Root
19
+ export function mountAll(root?: MountRoot): void
20
+ export function unmountElement(element: HTMLElement): void
21
+ export function unmountAll(root?: MountRoot): void
22
+ export function start(): void
23
+ export function stop(): void
24
+
25
+ export const OPERATION_STATES: readonly [
26
+ "pending",
27
+ "queued",
28
+ "running",
29
+ "retrying",
30
+ "completed",
31
+ "failed",
32
+ "cancelled"
33
+ ]
34
+ export const TERMINAL_OPERATION_STATES: readonly ["completed", "failed", "cancelled"]
35
+
36
+ export type OperationStateName = (typeof OPERATION_STATES)[number]
37
+ export type TerminalOperationStateName = (typeof TERMINAL_OPERATION_STATES)[number]
38
+ export type OperationIgnoredReason = "duplicate" | "operation_mismatch" | "out_of_order" | "terminal"
39
+
40
+ export interface OperationError<Details = unknown> {
41
+ code: string | null
42
+ message: string | null
43
+ retryable: boolean
44
+ details: Details | null
45
+ }
46
+
47
+ export interface OperationEvent<Result = unknown, ResultMetadata = unknown, ErrorDetails = unknown> {
48
+ operationId: string | null
49
+ id: string | null
50
+ idempotencyKey: string | null
51
+ sequence: number | null
52
+ state: OperationStateName | "unknown"
53
+ progress: number | null
54
+ message: string | null
55
+ result: Result | null
56
+ resultMetadata: ResultMetadata | null
57
+ error: OperationError<ErrorDetails> | null
58
+ occurredAt: string | null
59
+ }
60
+
61
+ export interface OperationEventInput<Result = unknown, ResultMetadata = unknown, ErrorDetails = unknown> {
62
+ operation_id?: string | null
63
+ operationId?: string | null
64
+ event_id?: string | null
65
+ eventId?: string | null
66
+ id?: string | null
67
+ idempotency_key?: string | null
68
+ idempotencyKey?: string | null
69
+ sequence?: number | string | null
70
+ state?: string | null
71
+ progress?: number | string | null
72
+ message?: string | null
73
+ result?: Result | null
74
+ result_metadata?: ResultMetadata | null
75
+ resultMetadata?: ResultMetadata | null
76
+ error?: string | Partial<OperationError<ErrorDetails>> | null
77
+ occurred_at?: string | null
78
+ occurredAt?: string | null
79
+ }
80
+
81
+ export class OperationProtocolError extends Error {
82
+ constructor(issues: string[])
83
+ issues: string[]
84
+ }
85
+
86
+ export function normalizeEvent<Result = unknown, ResultMetadata = unknown, ErrorDetails = unknown>(
87
+ event?: OperationEventInput<Result, ResultMetadata, ErrorDetails> | null
88
+ ): OperationEvent<Result, ResultMetadata, ErrorDetails>
89
+ export function validateOperationEvent<Result = unknown, ResultMetadata = unknown, ErrorDetails = unknown>(
90
+ event: unknown
91
+ ): OperationEvent<Result, ResultMetadata, ErrorDetails>
92
+
93
+ export interface OperationStateInitial<Result = unknown, ResultMetadata = unknown, ErrorDetails = unknown>
94
+ extends OperationEventInput<Result, ResultMetadata, ErrorDetails> {
95
+ occurredAt?: string | null
96
+ }
97
+
98
+ export interface AppliedOperationEvent<Result = unknown, ResultMetadata = unknown, ErrorDetails = unknown> {
99
+ applied: true
100
+ reason: null
101
+ value: OperationEvent<Result, ResultMetadata, ErrorDetails>
102
+ }
103
+
104
+ export interface IgnoredOperationEvent<Result = unknown, ResultMetadata = unknown, ErrorDetails = unknown> {
105
+ applied: false
106
+ reason: OperationIgnoredReason
107
+ value: OperationEvent<Result, ResultMetadata, ErrorDetails>
108
+ }
109
+
110
+ export type OperationEventOutcome<Result = unknown, ResultMetadata = unknown, ErrorDetails = unknown> =
111
+ | AppliedOperationEvent<Result, ResultMetadata, ErrorDetails>
112
+ | IgnoredOperationEvent<Result, ResultMetadata, ErrorDetails>
113
+
114
+ export class OperationState<Result = unknown, ResultMetadata = unknown, ErrorDetails = unknown> {
115
+ constructor(initial?: OperationStateInitial<Result, ResultMetadata, ErrorDetails>)
116
+ value: OperationEvent<Result, ResultMetadata, ErrorDetails>
117
+ readonly lastSequence: number | null
118
+ apply(event: OperationEventInput<Result, ResultMetadata, ErrorDetails>): OperationEvent<Result, ResultMetadata, ErrorDetails>
119
+ applyEvent(event: OperationEventInput<Result, ResultMetadata, ErrorDetails>): OperationEventOutcome<Result, ResultMetadata, ErrorDetails>
120
+ ignored(reason: OperationIgnoredReason): IgnoredOperationEvent<Result, ResultMetadata, ErrorDetails>
121
+ operationMismatch(event: OperationEvent<Result, ResultMetadata, ErrorDetails>): boolean
122
+ outOfOrder(event: OperationEvent<Result, ResultMetadata, ErrorDetails>): boolean
123
+ terminal(): boolean
124
+ }
125
+
126
+ export interface OperationAccessibility {
127
+ role: "alert" | "status"
128
+ "aria-live": "assertive" | "polite"
129
+ "aria-busy": boolean
130
+ }
131
+
132
+ export function operationAccessibility(
133
+ operation?: OperationState | Pick<OperationEvent, "state"> | null
134
+ ): OperationAccessibility
135
+
136
+ export type CableIdentifier = { channel: string } & Record<string, unknown>
137
+
138
+ export interface CableSubscription {
139
+ perform(action: string, data?: Record<string, unknown>): unknown
140
+ unsubscribe(): void
141
+ }
142
+
143
+ export interface CableCallbacks<Raw = unknown> {
144
+ connected(): void
145
+ disconnected(details?: unknown): void
146
+ rejected(): void
147
+ received(raw: Raw): void
148
+ }
149
+
150
+ export interface CableConsumer<Raw = unknown> {
151
+ subscriptions: {
152
+ create(identifier: CableIdentifier, callbacks: CableCallbacks<Raw>): CableSubscription
153
+ }
154
+ }
155
+
156
+ export interface SubscribeToOperationOptions<Result = unknown, ResultMetadata = unknown, ErrorDetails = unknown> {
157
+ consumer: CableConsumer
158
+ channel: string
159
+ params?: Record<string, unknown>
160
+ operationState?: OperationState<Result, ResultMetadata, ErrorDetails> | null
161
+ strict?: boolean
162
+ resume?: boolean
163
+ onEvent?: (
164
+ event: OperationEvent<Result, ResultMetadata, ErrorDetails>,
165
+ current: OperationEvent<Result, ResultMetadata, ErrorDetails>
166
+ ) => void
167
+ onIgnoredEvent?: (
168
+ event: OperationEvent<Result, ResultMetadata, ErrorDetails>,
169
+ reason: OperationIgnoredReason
170
+ ) => void
171
+ onProtocolError?: (error: unknown, raw: unknown) => void
172
+ onConnected?: (details: { resumeFrom: number | null }) => void
173
+ onDisconnected?: (details?: unknown) => void
174
+ onRejected?: () => void
175
+ }
176
+
177
+ export function subscribeToOperation<Result = unknown, ResultMetadata = unknown, ErrorDetails = unknown>(
178
+ options: SubscribeToOperationOptions<Result, ResultMetadata, ErrorDetails>
179
+ ): CableSubscription
180
+
181
+ export interface RequestOperationCancellationOptions {
182
+ url: string
183
+ operationId: string
184
+ csrfToken?: string | null
185
+ fetchImpl?: typeof fetch
186
+ }
187
+
188
+ export function requestOperationCancellation<Response = unknown>(
189
+ options: RequestOperationCancellationOptions
190
+ ): Promise<Response>
191
+
192
+ export class OperationCancellationError<Response = unknown> extends Error {
193
+ constructor(status: number, response: Response)
194
+ status: number
195
+ response: Response
196
+ }
197
+
198
+ export interface ResumableCursor {
199
+ current(): number
200
+ advance(sequence: number): void
201
+ }
202
+
203
+ export interface ParsedDelivery<Event> {
204
+ event: Event
205
+ sequence: number
206
+ }
207
+
208
+ export type ResumableStatus = "connected" | "disconnected" | "rejected"
209
+
210
+ export interface ResumableSubscription {
211
+ unsubscribe(): void
212
+ }
213
+
214
+ export interface SubscribeResumableOptions<Event> {
215
+ consumer: CableConsumer
216
+ channel: string
217
+ params?: Record<string, unknown>
218
+ cursor: ResumableCursor
219
+ parse(raw: unknown): ParsedDelivery<Event>
220
+ onEvent(event: Event): void
221
+ onStatus?: (status: ResumableStatus, details?: unknown) => void
222
+ onProtocolError?: (error: Error, raw: unknown) => void
223
+ }
224
+
225
+ export function subscribeResumable<Event>(options: SubscribeResumableOptions<Event>): ResumableSubscription
@@ -4,6 +4,7 @@ export { clearComponents, registerComponent, resolveComponent } from "./registry
4
4
  export { mountAll, mountElement, start, stop, unmountAll, unmountElement } from "./runtime"
5
5
  export { OperationCancellationError, requestOperationCancellation, subscribeToOperation } from "./cable"
6
6
  export { OperationState, operationAccessibility } from "./operation"
7
+ export { subscribeResumable } from "./resumable"
7
8
  export {
8
9
  normalizeEvent,
9
10
  OPERATION_STATES,
@@ -0,0 +1,133 @@
1
+ // app/javascript/active_admin/react/resumable.js
2
+
3
+ /**
4
+ * @typedef {object} ResumableCursor
5
+ * @property {() => number} current
6
+ * @property {(sequence: number) => void} advance
7
+ */
8
+
9
+ /**
10
+ * @template Event
11
+ * @typedef {object} ParsedDelivery
12
+ * @property {Event} event
13
+ * @property {number} sequence
14
+ */
15
+
16
+ /** @typedef {"connected" | "disconnected" | "rejected"} ResumableStatus */
17
+
18
+ /**
19
+ * @typedef {object} ResumableSubscription
20
+ * @property {() => void} unsubscribe
21
+ */
22
+
23
+ /**
24
+ * Subscribe to an application-owned, monotonically sequenced Action Cable stream.
25
+ *
26
+ * @template Event
27
+ * @param {object} options
28
+ * @param {{ subscriptions: { create: Function } }} options.consumer
29
+ * @param {string} options.channel
30
+ * @param {Record<string, unknown>} [options.params]
31
+ * @param {ResumableCursor} options.cursor
32
+ * @param {(raw: unknown) => ParsedDelivery<Event>} options.parse
33
+ * @param {(event: Event) => void} options.onEvent
34
+ * @param {(status: ResumableStatus, details?: unknown) => void} [options.onStatus]
35
+ * @param {(error: Error, raw: unknown) => void} [options.onProtocolError]
36
+ * @returns {ResumableSubscription}
37
+ */
38
+ export function subscribeResumable({
39
+ consumer,
40
+ channel,
41
+ params = {},
42
+ cursor,
43
+ parse,
44
+ onEvent,
45
+ onStatus,
46
+ onProtocolError
47
+ }) {
48
+ validateDependencies({ consumer, channel, cursor, parse, onEvent })
49
+
50
+ let subscription
51
+ let unsubscribed = false
52
+
53
+ const reportProtocolError = (error, raw) => {
54
+ const normalized = error instanceof Error ? error : new Error(String(error))
55
+ if (onProtocolError) {
56
+ onProtocolError(normalized, raw)
57
+ return
58
+ }
59
+ throw normalized
60
+ }
61
+
62
+ const readCursor = (raw) => {
63
+ try {
64
+ return safeSequence(cursor.current(), "cursor.current()")
65
+ } catch (error) {
66
+ reportProtocolError(error, raw)
67
+ return null
68
+ }
69
+ }
70
+
71
+ subscription = consumer.subscriptions.create({ ...params, channel }, {
72
+ connected() {
73
+ const current = readCursor(undefined)
74
+ if (current === null) return
75
+
76
+ subscription.perform("resume", { after_sequence: current })
77
+ onStatus?.("connected")
78
+ },
79
+ disconnected(details) {
80
+ onStatus?.("disconnected", details)
81
+ },
82
+ rejected() {
83
+ onStatus?.("rejected")
84
+ },
85
+ received(raw) {
86
+ let parsed
87
+ let current
88
+
89
+ try {
90
+ parsed = parse(raw)
91
+ safeSequence(parsed?.sequence, "parsed sequence")
92
+ current = safeSequence(cursor.current(), "cursor.current()")
93
+ } catch (error) {
94
+ reportProtocolError(error, raw)
95
+ return
96
+ }
97
+
98
+ if (parsed.sequence <= current) return
99
+
100
+ onEvent(parsed.event)
101
+
102
+ try {
103
+ cursor.advance(parsed.sequence)
104
+ } catch (error) {
105
+ reportProtocolError(error, raw)
106
+ }
107
+ }
108
+ })
109
+
110
+ return {
111
+ unsubscribe() {
112
+ if (unsubscribed) return
113
+
114
+ unsubscribed = true
115
+ subscription.unsubscribe()
116
+ }
117
+ }
118
+ }
119
+
120
+ function validateDependencies({ consumer, channel, cursor, parse, onEvent }) {
121
+ if (typeof consumer?.subscriptions?.create !== "function") throw new Error("consumer is required")
122
+ if (typeof channel !== "string" || channel.trim().length === 0) throw new Error("channel is required")
123
+ if (typeof cursor?.current !== "function" || typeof cursor?.advance !== "function") {
124
+ throw new Error("cursor is required")
125
+ }
126
+ if (typeof parse !== "function") throw new Error("parse is required")
127
+ if (typeof onEvent !== "function") throw new Error("onEvent is required")
128
+ }
129
+
130
+ function safeSequence(value, label) {
131
+ if (!Number.isSafeInteger(value) || value < 0) throw new Error(`${label} must be a non-negative safe integer`)
132
+ return value
133
+ }
@@ -16,10 +16,18 @@ function mountOnTurboLoad() {
16
16
  mountAll()
17
17
  }
18
18
 
19
+ function mountAfterTurboRender() {
20
+ mountAll()
21
+ }
22
+
19
23
  function unmountBeforeTurboCache() {
20
24
  unmountAll()
21
25
  }
22
26
 
27
+ function unmountBeforeTurboRender() {
28
+ unmountAll()
29
+ }
30
+
23
31
  function propsFor(element) {
24
32
  const raw = element.dataset.reactProps || "{}"
25
33
  return JSON.parse(raw)
@@ -60,6 +68,8 @@ export function start() {
60
68
  mountAll()
61
69
  document.addEventListener("turbo:load", mountOnTurboLoad)
62
70
  document.addEventListener("turbo:before-cache", unmountBeforeTurboCache)
71
+ document.addEventListener("turbo:before-render", unmountBeforeTurboRender)
72
+ document.addEventListener("turbo:render", mountAfterTurboRender)
63
73
  started = true
64
74
  }
65
75
 
@@ -69,5 +79,7 @@ export function stop() {
69
79
  unmountAll()
70
80
  document.removeEventListener("turbo:load", mountOnTurboLoad)
71
81
  document.removeEventListener("turbo:before-cache", unmountBeforeTurboCache)
82
+ document.removeEventListener("turbo:before-render", unmountBeforeTurboRender)
83
+ document.removeEventListener("turbo:render", mountAfterTurboRender)
72
84
  started = false
73
85
  }
data/docs/README.md CHANGED
@@ -3,8 +3,11 @@
3
3
  # Documentation
4
4
 
5
5
  - [Project README](../README.md) — installation, compatibility, public APIs, supported asset integration, security, development, and troubleshooting.
6
+ - [TypeScript consumer contract](typescript.md) — declaration discovery, public type coverage, and strict consumer verification.
7
+ - [Resumable subscription contract](resumable-subscriptions.md) — generic Action Cable resume, cursor, protocol-error, and ownership semantics.
8
+ - [Cable delivery contract](cable-delivery.md) — best-effort broadcast semantics, diagnostics, privacy, and host ownership.
6
9
  - [Release process](releasing.md) — maintainer validation, RubyGems Trusted Publishing, failure handling, and post-release verification.
7
- - [Release policy](../RELEASES.md) — ordinary pre-1.0 versioning and the path to `1.0.0.rcN` and `1.0.0`.
10
+ - [Release policy](../RELEASES.md) — unsuffixed ordinary pre-1.0 versions and explicitly authorized major-release stabilization trains.
8
11
  - [Changelog](../CHANGELOG.md) — release notes for shipped and upcoming versions.
9
12
  - [MIT License](../LICENSE.txt) — terms for using and distributing ActiveAdmin React.
10
13
 
@@ -0,0 +1,35 @@
1
+ <!-- docs/cable-delivery.md -->
2
+
3
+ # Best-effort Action Cable delivery
4
+
5
+ `ActiveAdmin::React::Cable.broadcast` is a narrow boundary for live projections of state that the host application has already committed. Action Cable improves responsiveness; it is never the source of truth.
6
+
7
+ ## Contract
8
+
9
+ ```ruby
10
+ result = ActiveAdmin::React::Cable.broadcast(
11
+ stream: "operations:report-123",
12
+ payload: { type: "progress", sequence: 7 },
13
+ context: { workflow: "operation", event_id: 42 }
14
+ )
15
+ ```
16
+
17
+ The method attempts exactly one `ActionCable.server.broadcast`. It returns a frozen `ActiveAdmin::React::Cable::BroadcastResult`: `success?` is true with a nil `error` after the adapter accepts the broadcast; otherwise `success?` is false and `error` is the rescued `StandardError`.
18
+
19
+ Broadcast after the transaction or lock that establishes durable truth. Never place persistence, enqueueing, or other authoritative work inside this failure boundary. The helper intentionally owns no retries, jobs, transactions, replay, event storage, or payload schema.
20
+
21
+ ## Failure observability
22
+
23
+ A failed broadcast produces two best-effort diagnostics:
24
+
25
+ - one `broadcast_failure.active_admin_react` notification with `stream`, `context`, and `error`;
26
+ - one Rails error log with the same bounded diagnostic identity.
27
+
28
+ The payload is never attached or logged by the helper. Context is caller-controlled, so keep it small and exclude credentials, personal information, tenant secrets, and full records. If logging or a notification subscriber fails, that diagnostic failure is contained and the original broadcast error remains available on the returned result.
29
+
30
+ Success notifications, custom adapters, callbacks, configurable rescue classes, and retry options are outside the v1 contract. Add them only after independent dogfood evidence demonstrates a stable need.
31
+
32
+ —
33
+ Stan Carver II
34
+ Made in Texas 🤠
35
+ https://stancarver.com
data/docs/releasing.md CHANGED
@@ -31,7 +31,7 @@ Before tagging, confirm every required GitHub Actions quality and package job is
31
31
 
32
32
  ## Publishing
33
33
 
34
- Create a tag that exactly matches the gem version with a leading `v`: `v0.MINOR.PATCH`, or an authorized prerelease `v0.MINOR.PATCH.alphaN` or `v0.MINOR.PATCH.betaN`. The first integrated prerelease is `v0.1.0.alpha1`. When Rodeo dogfooding indicates readiness to converge on 1.0, use `v1.0.0.rcN`. All numeric components have no leading zeroes, and prerelease `N` starts at 1. Other suffixes, `0.x` release candidates, `1.0.0` alpha/beta versions, and build metadata are rejected by the guard.
34
+ Create an ordinary pre-1.0 tag that exactly matches the gem version with a leading `v`: `v0.MINOR.PATCH`. The historical first integrated dogfooding prerelease was `v0.1.0.alpha1`; ordinary `0.x` development no longer uses `alpha`, `beta`, or `rc` suffixes. Reserve those suffixes for an explicitly authorized major-release stabilization train. The current guard also permits the previously authorized future `v1.0.0.rcN` shape. All numeric components have no leading zeroes, prerelease `N` starts at 1, and suffixed `0.x` versions, unauthorized major prereleases, arbitrary suffixes, and build metadata are rejected.
35
35
 
36
36
  Push the tag only from the reviewed `master` commit. The tag-triggered `.github/workflows/release.yml` job checks out that commit and publishes through the protected `release` environment and RubyGems Trusted Publishing. Approving that environment deployment authorizes publication; local validation never does.
37
37
 
@@ -0,0 +1,56 @@
1
+ <!-- docs/resumable-subscriptions.md -->
2
+
3
+ # Resumable Action Cable subscriptions
4
+
5
+ `subscribeResumable` provides a narrow client-side lifecycle for application-owned streams with monotonically increasing integer sequences. It does not define a domain event schema.
6
+
7
+ ## Public contract
8
+
9
+ The host supplies an Action Cable consumer, channel, optional identifier parameters, a cursor, a parser, and an event handler:
10
+
11
+ ```js
12
+ const result = subscribeResumable({
13
+ consumer,
14
+ channel: "EventsChannel",
15
+ params: { stream_id: streamId },
16
+ cursor: {
17
+ current: () => latestSequence,
18
+ advance: (sequence) => { latestSequence = sequence }
19
+ },
20
+ parse: (raw) => ({ event: validateEvent(raw), sequence: raw.sequence }),
21
+ onEvent: (event) => applyEvent(event)
22
+ })
23
+ ```
24
+
25
+ The channel name supplied by `channel` always wins over a `channel` key in `params`. On initial connection and every reconnect, the helper performs exactly:
26
+
27
+ ```js
28
+ subscription.perform("resume", { after_sequence: cursor.current() })
29
+ ```
30
+
31
+ The `resume` action name and `after_sequence` parameter are fixed v1 protocol. The server authorizes the stream, queries durable events after that cursor, and orders replay before buffered live delivery.
32
+
33
+ ## Cursor and delivery semantics
34
+
35
+ `cursor.current()` and every sequence returned by `parse` must be non-negative JavaScript safe integers. A parsed sequence less than or equal to the current cursor is ignored.
36
+
37
+ For a fresh sequence, the helper calls `onEvent(event)` first. It calls `cursor.advance(sequence)` only after the handler returns successfully. A handler exception remains loud and leaves the cursor unchanged, allowing the host's replay contract to redeliver the event.
38
+
39
+ Parsing failures, invalid cursor values, invalid parsed sequences, and cursor callback failures are protocol errors. When `onProtocolError` is supplied, it receives `(error, raw)`; otherwise the error is thrown. Domain handler and lifecycle callback failures are not converted into protocol errors.
40
+
41
+ ## Lifecycle and ownership
42
+
43
+ One optional status callback receives `"connected"`, `"disconnected"`, or `"rejected"`; disconnect details are passed as its second argument. The returned `unsubscribe()` is idempotent and removes only the created subscription. It never disconnects the consumer because a host may share one consumer among many islands or streams.
44
+
45
+ The host calls `unsubscribe()` from its React effect cleanup. The helper relies on Action Cable's existing reconnect behavior and adds no retry or backoff policy.
46
+
47
+ ## Deliberate exclusions
48
+
49
+ The v1 API does not own domain reducers, terminal states, React state, rendering, consumer construction, authorization, replay storage, transactions, configurable action names, ignored-event callbacks, per-lifecycle callbacks, or reset/epoch semantics. A workflow that resets its sequence must establish a new stream identity or adopt a durable monotonic sequence before using this helper.
50
+
51
+ `subscribeToOperation` remains a separate compatibility API with its existing operation-specific protocol.
52
+
53
+ —
54
+ Stan Carver II
55
+ Made in Texas 🤠
56
+ https://stancarver.com
@@ -0,0 +1,46 @@
1
+ <!-- docs/typescript.md -->
2
+
3
+ # TypeScript consumer contract
4
+
5
+ ActiveAdmin React packages `index.d.ts` beside its supported
6
+ `app/javascript/active_admin/react/index.js` entrypoint. The declarations cover every
7
+ runtime export from that entrypoint and export the reusable option, event, result,
8
+ cursor, and Action Cable structural types used by those APIs.
9
+
10
+ ## Declaration discovery
11
+
12
+ Keep the runtime build-tool alias pointed at `index.js`. Configure TypeScript's
13
+ `active_admin/react` path to the containing `app/javascript/active_admin/react`
14
+ directory so the compiler selects `index.d.ts`. A host should derive the installed gem
15
+ root from `bundle show activeadmin-react`; a vendored bundle may use its stable relative
16
+ path directly.
17
+
18
+ Do not copy the declaration into the host or add an ambient
19
+ `declare module "active_admin/react"` workaround. That would detach the application
20
+ contract from the installed gem version. The declaration imports official React types,
21
+ so the host provides `@types/react` and `@types/react-dom` versions compatible with its
22
+ React runtime.
23
+
24
+ ## Public typing boundary
25
+
26
+ The declarations describe component registration and mounting, Turbo lifecycle
27
+ control, operation normalization and reduction, cancellation, operation subscriptions,
28
+ and generic resumable subscriptions. Known option and result shapes use named types;
29
+ application-owned payloads remain generic rather than being widened to a gem-owned
30
+ domain schema.
31
+
32
+ `CableConsumer` and `CableSubscription` are structural contracts. Hosts may pass the
33
+ corresponding Action Cable objects without an adapter. `subscribeResumable<Event>`
34
+ types the parsed delivery and handler with the host's event type while retaining the
35
+ fixed non-negative safe-integer cursor protocol documented in
36
+ [Resumable Action Cable subscriptions](resumable-subscriptions.md).
37
+
38
+ The repository's strict independent consumer imports only `active_admin/react`. Its
39
+ valid usage must typecheck, while separately compiled marked invalid calls must produce
40
+ TypeScript diagnostics. Gem package and isolated-install validation also require the
41
+ declaration file to be present.
42
+
43
+ —
44
+ Stan Carver II
45
+ Made in Texas 🤠
46
+ https://stancarver.com
@@ -0,0 +1,58 @@
1
+ # lib/active_admin/react/cable.rb
2
+ # frozen_string_literal: true
3
+
4
+ require 'action_cable'
5
+ require 'active_support/notifications'
6
+
7
+ module ActiveAdmin
8
+ module React
9
+ # Delivers non-authoritative Action Cable projections without changing durable application truth.
10
+ module Cable
11
+ FAILURE_EVENT = 'broadcast_failure.active_admin_react'
12
+
13
+ # Immutable outcome of one best-effort broadcast attempt.
14
+ class BroadcastResult
15
+ attr_reader :error
16
+
17
+ def initialize(error:)
18
+ @error = error
19
+ freeze
20
+ end
21
+
22
+ def success?
23
+ error.nil?
24
+ end
25
+ end
26
+
27
+ module_function
28
+
29
+ def broadcast(stream:, payload:, context: {})
30
+ ActionCable.server.broadcast(stream, payload)
31
+ BroadcastResult.new(error: nil)
32
+ rescue StandardError => e
33
+ report_failure(stream:, context:, error: e)
34
+ BroadcastResult.new(error: e)
35
+ end
36
+
37
+ def report_failure(stream:, context:, error:)
38
+ safely do
39
+ Rails.logger.error(
40
+ "ActiveAdmin React Cable broadcast failed: stream=#{stream.inspect} " \
41
+ "context=#{context.inspect} error=#{error.class}: #{error.message}"
42
+ )
43
+ end
44
+ safely do
45
+ ActiveSupport::Notifications.instrument(FAILURE_EVENT, stream:, context:, error:)
46
+ end
47
+ end
48
+ private_class_method :report_failure
49
+
50
+ def safely
51
+ yield
52
+ rescue StandardError
53
+ nil
54
+ end
55
+ private_class_method :safely
56
+ end
57
+ end
58
+ end
@@ -3,6 +3,6 @@
3
3
 
4
4
  module ActiveAdmin
5
5
  module React
6
- VERSION = '0.1.0.alpha1'
6
+ VERSION = '0.3.0'
7
7
  end
8
8
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'active_admin'
4
4
  require_relative 'react/arbre'
5
+ require_relative 'react/cable'
5
6
  require_relative 'react/contributions'
6
7
  require_relative 'react/mount'
7
8
  require_relative 'react/registry'
@@ -2,6 +2,19 @@
2
2
 
3
3
  module ActiveAdmin
4
4
  module React
5
+ module Cable
6
+ FAILURE_EVENT: String
7
+
8
+ class BroadcastResult
9
+ attr_reader error: StandardError?
10
+
11
+ def initialize: (error: StandardError?) -> void
12
+ def success?: () -> bool
13
+ end
14
+
15
+ def self.broadcast: (stream: String, payload: Hash[String | Symbol, untyped], ?context: Hash[String | Symbol, untyped]) -> BroadcastResult
16
+ end
17
+
5
18
  class Registry
6
19
  class Entry
7
20
  attr_reader name: String
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: activeadmin-react
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0.alpha1
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stan Carver II
@@ -15,7 +15,7 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: 4.0.0.beta22
18
+ version: 4.0.0.beta23
19
19
  - - "<"
20
20
  - !ruby/object:Gem::Version
21
21
  version: '5'
@@ -25,7 +25,7 @@ dependencies:
25
25
  requirements:
26
26
  - - ">="
27
27
  - !ruby/object:Gem::Version
28
- version: 4.0.0.beta22
28
+ version: 4.0.0.beta23
29
29
  - - "<"
30
30
  - !ruby/object:Gem::Version
31
31
  version: '5'
@@ -62,15 +62,21 @@ files:
62
62
  - README.md
63
63
  - RELEASES.md
64
64
  - app/javascript/active_admin/react/cable.js
65
+ - app/javascript/active_admin/react/index.d.ts
65
66
  - app/javascript/active_admin/react/index.js
66
67
  - app/javascript/active_admin/react/operation.js
67
68
  - app/javascript/active_admin/react/protocol.js
68
69
  - app/javascript/active_admin/react/registry.js
70
+ - app/javascript/active_admin/react/resumable.js
69
71
  - app/javascript/active_admin/react/runtime.js
70
72
  - docs/README.md
73
+ - docs/cable-delivery.md
71
74
  - docs/releasing.md
75
+ - docs/resumable-subscriptions.md
76
+ - docs/typescript.md
72
77
  - lib/active_admin/react.rb
73
78
  - lib/active_admin/react/arbre.rb
79
+ - lib/active_admin/react/cable.rb
74
80
  - lib/active_admin/react/contributions.rb
75
81
  - lib/active_admin/react/mount.rb
76
82
  - lib/active_admin/react/registry.rb
@@ -81,11 +87,11 @@ licenses:
81
87
  - MIT
82
88
  metadata:
83
89
  bug_tracker_uri: https://github.com/scarver2/activeadmin-react/issues
84
- changelog_uri: https://github.com/scarver2/activeadmin-react/blob/v0.1.0.alpha1/CHANGELOG.md
85
- documentation_uri: https://github.com/scarver2/activeadmin-react/blob/v0.1.0.alpha1/README.md
90
+ changelog_uri: https://github.com/scarver2/activeadmin-react/blob/v0.3.0/CHANGELOG.md
91
+ documentation_uri: https://github.com/scarver2/activeadmin-react/blob/v0.3.0/README.md
86
92
  homepage_uri: https://github.com/scarver2/activeadmin-react
87
93
  rubygems_mfa_required: 'true'
88
- source_code_uri: https://github.com/scarver2/activeadmin-react/tree/v0.1.0.alpha1
94
+ source_code_uri: https://github.com/scarver2/activeadmin-react/tree/v0.3.0
89
95
  rdoc_options: []
90
96
  require_paths:
91
97
  - lib
@@ -93,7 +99,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
93
99
  requirements:
94
100
  - - ">="
95
101
  - !ruby/object:Gem::Version
96
- version: '3.2'
102
+ version: '3.3'
97
103
  required_rubygems_version: !ruby/object:Gem::Requirement
98
104
  requirements:
99
105
  - - ">="