react-on-rails 17.0.0-rc.6 → 17.0.0-rc.7

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.
package/README.md CHANGED
@@ -81,11 +81,12 @@ ReactOnRails.register({
81
81
 
82
82
  ## Exports
83
83
 
84
- | Export Path | Description |
85
- | ----------------------- | ----------------------------------- |
86
- | `react-on-rails` | Full build with SSR utilities |
87
- | `react-on-rails/client` | Client-only build (smaller, no SSR) |
88
- | `react-on-rails/types` | TypeScript type exports |
84
+ | Export Path | Description |
85
+ | ---------------------------- | ------------------------------------ |
86
+ | `react-on-rails` | Full build with SSR utilities |
87
+ | `react-on-rails/client` | Client-only build (smaller, no SSR) |
88
+ | `react-on-rails/railsAction` | CSRF-aware typed Rails action caller |
89
+ | `react-on-rails/types` | TypeScript type exports |
89
90
 
90
91
  ## Rails-Side Setup
91
92
 
@@ -17,8 +17,8 @@ const renderedRoots = new Map();
17
17
  * Invokes a renderer teardown, swallowing async rejections so a failing teardown cannot produce an
18
18
  * unhandled promise rejection. Synchronous throws propagate to the caller's try/catch. `domNodeId`
19
19
  * is included in the log so a failure can be traced to its mount.
20
- * MUST SYNC: A sibling helper exists in packages/react-on-rails-pro/src/ClientSideRenderer.ts. If you
21
- * change the error-handling logic or log format here, update that copy too.
20
+ * MIRROR OF: packages/react-on-rails-pro/src/ClientSideRenderer.ts (sibling helper). If you change
21
+ * the error-handling logic or log format here, update that copy too.
22
22
  */
23
23
  function invokeRendererTeardown(teardown, domNodeId) {
24
24
  if (!teardown)
@@ -118,6 +118,7 @@ function scheduleWhenVisible(domNode, callback) {
118
118
  const target = entries[0]?.target;
119
119
  if (target && !target.isConnected) {
120
120
  observer.disconnect();
121
+ callback();
121
122
  return;
122
123
  }
123
124
  const isVisible = entries.some((entry) => entry.isIntersecting || entry.intersectionRatio > 0);
@@ -270,7 +271,9 @@ function trackRendererMount(domNodeId, domNode, result) {
270
271
  * delegates to a renderer registered by the user.
271
272
  */
272
273
  function renderElement(el, railsContext) {
273
- // This must match lib/react_on_rails/helper.rb
274
+ // This must match the data-* attributes emitted by lib/react_on_rails/pro_helper.rb.
275
+ // Covered end-to-end by every spec/dummy client-rendering test (renaming either side fails
276
+ // loudly), so no MIRROR VALUES guard is added here.
274
277
  const name = el.getAttribute('data-component-name') || '';
275
278
  const domNodeId = domNodeIdForEl(el);
276
279
  const props = el.textContent !== null ? JSON.parse(el.textContent) : {};
@@ -29,6 +29,10 @@ declare const _default: {
29
29
  * Internally used function to completely clear hydratedStores Map.
30
30
  */
31
31
  clearHydratedStores(): void;
32
+ /**
33
+ * Internally used function to completely clear registeredStoreGenerators Map.
34
+ */
35
+ clearStoreGenerators(): void;
32
36
  /**
33
37
  * Get a Map containing all registered store generators. Useful for debugging.
34
38
  * @returns Map where key is the component name and values are the store generators.
@@ -33,6 +33,9 @@ export default {
33
33
  if (hydratedStores.has(name)) {
34
34
  return hydratedStores.get(name);
35
35
  }
36
+ if (!throwIfMissing) {
37
+ return undefined;
38
+ }
36
39
  const storeKeys = Array.from(hydratedStores.keys()).join(', ');
37
40
  if (storeKeys.length === 0) {
38
41
  const msg = `There are no stores hydrated and you are requesting the store ${name}.
@@ -42,12 +45,8 @@ This can happen if you are server rendering and either:
42
45
  2. You do not render redux_store_hydration_data anywhere on your page.`;
43
46
  throw new Error(msg);
44
47
  }
45
- if (throwIfMissing) {
46
- console.log('storeKeys', storeKeys);
47
- throw new Error(`Could not find hydrated store with name '${name}'. ` +
48
- `Hydrated store names include [${storeKeys}].`);
49
- }
50
- return undefined;
48
+ console.log('storeKeys', storeKeys);
49
+ throw new Error(`Could not find hydrated store with name '${name}'. Hydrated store names include [${storeKeys}].`);
51
50
  },
52
51
  /**
53
52
  * Internally used function to get the store creator that was passed to `register`.
@@ -77,6 +76,12 @@ This can happen if you are server rendering and either:
77
76
  clearHydratedStores() {
78
77
  hydratedStores.clear();
79
78
  },
79
+ /**
80
+ * Internally used function to completely clear registeredStoreGenerators Map.
81
+ */
82
+ clearStoreGenerators() {
83
+ registeredStoreGenerators.clear();
84
+ },
80
85
  /**
81
86
  * Get a Map containing all registered store generators. Useful for debugging.
82
87
  * @returns Map where key is the component name and values are the store generators.
@@ -16,6 +16,7 @@ interface Registries {
16
16
  getStoreGenerator: (name: string) => StoreGenerator;
17
17
  setStore: (name: string, store: Store) => void;
18
18
  clearHydratedStores: () => void;
19
+ clearStoreGenerators: () => void;
19
20
  storeGenerators: () => Map<string, StoreGenerator>;
20
21
  stores: () => Map<string, Store>;
21
22
  };
@@ -2,23 +2,27 @@
2
2
  * @deprecated Use `capabilities/core.ts` instead. This file is kept for backward compatibility
3
3
  * with older versions of react-on-rails-pro that import from `react-on-rails/@internal/base/client`.
4
4
  */
5
- import * as Authenticity from "../Authenticity.js";
6
- import buildConsoleReplay, { consoleReplay } from "../buildConsoleReplay.js";
7
- import reactHydrateOrRender from "../reactHydrateOrRender.js";
8
- import createReactOutput from "../createReactOutput.js";
9
- import componentRegistrationMetric from "../componentRegistrationMetric.js";
10
- import { buildRootErrorCallbackOptions, getRootErrorHandlers, resetRootErrorHandlers, setRootErrorHandlers, } from "../rootErrorHandlers.js";
11
- const DEFAULT_OPTIONS = {
12
- traceTurbolinks: false,
13
- turbo: false,
14
- debugMode: false,
15
- logComponentRegistration: false,
16
- };
5
+ import { createCoreCapability } from "../capabilities/core.js";
6
+ // Pro-only methods that `createCoreCapability` includes as stubs but the historical base
7
+ // surface omits. This list drives the runtime key-stripping in `createBaseClientObject`.
8
+ // `satisfies readonly (keyof ReactOnRailsInternal)[]` makes a typo or a stale name a compile
9
+ // error, and the `assertProOnlyMethodsMatchOmit` guard inside `createBaseClientObject` pins this
10
+ // list to the `Omit<...>` in `BaseClientObjectType` so the runtime list and the exported type can
11
+ // never drift.
12
+ const PRO_ONLY_METHODS = [
13
+ 'getOrWaitForComponent',
14
+ 'getOrWaitForStore',
15
+ 'getOrWaitForStoreGenerator',
16
+ 'reactOnRailsStoreLoaded',
17
+ 'streamServerRenderedReactComponent',
18
+ 'serverRenderRSCReactComponent',
19
+ 'addAsyncPropsCapabilityToComponentProps',
20
+ 'getOrCreateAsyncPropsManager',
21
+ ];
17
22
  // Cache to track created objects and their registries
18
23
  let cachedObject = null;
19
24
  let cachedRegistries = null;
20
25
  export function createBaseClientObject(registries, currentObject = null) {
21
- const { ComponentRegistry, StoreRegistry } = registries;
22
26
  // Error detection: currentObject is null but we have a cached object
23
27
  // This indicates webpack misconfiguration (multiple runtime chunks)
24
28
  if (currentObject === null && cachedObject !== null) {
@@ -60,168 +64,25 @@ Fix: Use only react-on-rails OR react-on-rails-pro, not both.`);
60
64
  if (cachedObject !== null) {
61
65
  return cachedObject;
62
66
  }
63
- // Create and return new object
64
- const obj = {
65
- options: {},
66
- isRSCBundle: false,
67
- // ===================================================================
68
- // STABLE METHOD IMPLEMENTATIONS - Core package implementations
69
- // ===================================================================
70
- authenticityToken() {
71
- return Authenticity.authenticityToken();
72
- },
73
- authenticityHeaders(otherHeaders = {}) {
74
- return Authenticity.authenticityHeaders(otherHeaders);
75
- },
76
- reactHydrateOrRender(domNode, reactElement, hydrate) {
77
- // The component name is unknown on this low-level path; the dom id still ties errors to a mount.
78
- const rootErrorCallbackOptions = buildRootErrorCallbackOptions({ domNodeId: domNode.id || undefined }, hydrate);
79
- return reactHydrateOrRender(domNode, reactElement, hydrate, rootErrorCallbackOptions);
80
- },
81
- setOptions(newOptions) {
82
- const { traceTurbolinks, turbo, debugMode, logComponentRegistration, rootErrorHandlers, ...rest } = newOptions;
83
- if (typeof traceTurbolinks !== 'undefined') {
84
- this.options.traceTurbolinks = traceTurbolinks;
85
- }
86
- if (typeof turbo !== 'undefined') {
87
- this.options.turbo = turbo;
88
- }
89
- if (typeof debugMode !== 'undefined') {
90
- this.options.debugMode = debugMode;
91
- if (debugMode) {
92
- console.log('[ReactOnRails] Debug mode enabled');
93
- }
94
- }
95
- if (typeof logComponentRegistration !== 'undefined') {
96
- this.options.logComponentRegistration = logComponentRegistration;
97
- if (logComponentRegistration) {
98
- console.log('[ReactOnRails] Component registration logging enabled');
99
- }
100
- }
101
- if (Object.prototype.hasOwnProperty.call(newOptions, 'rootErrorHandlers')) {
102
- // MUST SYNC: sibling implementation exists in packages/react-on-rails/src/capabilities/core.ts.
103
- // Validates and merges the handlers per key (partial updates keep previously registered
104
- // callbacks); warns when the React runtime cannot support them. Store the merged result so
105
- // `option('rootErrorHandlers')` reflects the effective registration.
106
- if (typeof rootErrorHandlers === 'undefined') {
107
- resetRootErrorHandlers();
108
- this.options.rootErrorHandlers = undefined;
109
- }
110
- else {
111
- setRootErrorHandlers(rootErrorHandlers);
112
- this.options.rootErrorHandlers = getRootErrorHandlers();
113
- }
114
- }
115
- if (Object.keys(rest).length > 0) {
116
- throw new Error(`Invalid options passed to ReactOnRails.options: ${JSON.stringify(rest)}`);
117
- }
118
- },
119
- option(key) {
120
- return this.options[key];
121
- },
122
- buildConsoleReplay() {
123
- return buildConsoleReplay();
124
- },
125
- getConsoleReplayScript() {
126
- return consoleReplay();
127
- },
128
- resetOptions() {
129
- this.options = { ...DEFAULT_OPTIONS };
130
- resetRootErrorHandlers();
131
- },
132
- // ===================================================================
133
- // REGISTRY METHOD IMPLEMENTATIONS - Using provided registries
134
- // ===================================================================
135
- register(components) {
136
- if (this.options.debugMode || this.options.logComponentRegistration) {
137
- // Use performance.now() if available, otherwise fallback to Date.now()
138
- const perf = typeof performance !== 'undefined' ? performance : { now: () => Date.now() };
139
- const startTime = perf.now();
140
- const componentNames = Object.keys(components);
141
- console.log(`[ReactOnRails] Registering ${componentNames.length} component(s): ${componentNames.join(', ')}`);
142
- ComponentRegistry.register(components);
143
- const endTime = perf.now();
144
- console.log(`[ReactOnRails] Component registration completed in ${(endTime - startTime).toFixed(2)}ms`);
145
- // Log individual component details if in full debug mode
146
- if (this.options.debugMode) {
147
- componentNames.forEach((name) => {
148
- const component = components[name];
149
- const registrationMetric = componentRegistrationMetric(component);
150
- console.log(`[ReactOnRails] ✅ Registered: ${name} (${registrationMetric.value} ${registrationMetric.label})`);
151
- });
152
- }
153
- }
154
- else {
155
- ComponentRegistry.register(components);
156
- }
157
- },
158
- registerStore(stores) {
159
- this.registerStoreGenerators(stores);
160
- },
161
- registerStoreGenerators(storeGenerators) {
162
- if (!storeGenerators) {
163
- throw new Error('Called ReactOnRails.registerStoreGenerators with a null or undefined, rather than ' +
164
- 'an Object with keys being the store names and the values are the store generators.');
165
- }
166
- StoreRegistry.register(storeGenerators);
167
- },
168
- getStore(name, throwIfMissing = true) {
169
- return StoreRegistry.getStore(name, throwIfMissing);
170
- },
171
- getStoreGenerator(name) {
172
- return StoreRegistry.getStoreGenerator(name);
173
- },
174
- setStore(name, store) {
175
- StoreRegistry.setStore(name, store);
176
- },
177
- clearHydratedStores() {
178
- StoreRegistry.clearHydratedStores();
179
- },
180
- getComponent(name) {
181
- return ComponentRegistry.get(name);
182
- },
183
- registeredComponents() {
184
- return ComponentRegistry.components();
185
- },
186
- storeGenerators() {
187
- return StoreRegistry.storeGenerators();
188
- },
189
- stores() {
190
- return StoreRegistry.stores();
191
- },
192
- render(name, props, domNodeId, hydrate) {
193
- const componentObj = ComponentRegistry.get(name);
194
- const reactElement = createReactOutput({ componentObj, props, domNodeId });
195
- return reactHydrateOrRender(document.getElementById(domNodeId), reactElement, hydrate, buildRootErrorCallbackOptions({ componentName: name || undefined, domNodeId: domNodeId || undefined }, hydrate));
196
- },
197
- // ===================================================================
198
- // CLIENT-SIDE RENDERING STUBS - To be overridden by createReactOnRails
199
- // ===================================================================
200
- reactOnRailsPageLoaded() {
201
- throw new Error('ReactOnRails.reactOnRailsPageLoaded is not initialized. This is a bug in react-on-rails.');
202
- },
203
- reactOnRailsComponentLoaded(domId) {
204
- void domId; // Mark as used
205
- throw new Error('ReactOnRails.reactOnRailsComponentLoaded is not initialized. This is a bug in react-on-rails.');
206
- },
207
- // ===================================================================
208
- // SSR STUBS - Will throw errors in client bundle, overridden in full
209
- // ===================================================================
210
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
211
- serverRenderReactComponent(...args) {
212
- void args; // Mark as used
213
- throw new Error('serverRenderReactComponent is not available in "react-on-rails/client". Import "react-on-rails" server-side.');
214
- },
215
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
216
- handleError(...args) {
217
- void args; // Mark as used
218
- throw new Error('handleError is not available in "react-on-rails/client". Import "react-on-rails" server-side.');
219
- },
220
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
221
- prepareRenderResult(...args) {
222
- void args; // Mark as used
223
- throw new Error('prepareRenderResult is not available in "react-on-rails/client". Import "react-on-rails" server-side.');
224
- },
67
+ const assertProOnlyMethodsMatchOmit = true;
68
+ void assertProOnlyMethodsMatchOmit;
69
+ // Delegate the core method implementations to `createCoreCapability` (the canonical source),
70
+ // then re-shape to the historical base surface: strip the Pro-only stubs (using the shared
71
+ // `PRO_ONLY_METHODS` list that the drift guard above pins to `BaseClientObjectType`, so runtime
72
+ // and type can never drift) and add the client-side lifecycle stubs that `createReactOnRails`
73
+ // overrides at initialization.
74
+ const proOnlyMethods = new Set(PRO_ONLY_METHODS);
75
+ const core = createCoreCapability(registries);
76
+ const obj = Object.fromEntries(Object.entries(core).filter(([key]) => !proOnlyMethods.has(key)));
77
+ // ===================================================================
78
+ // CLIENT-SIDE RENDERING STUBS - To be overridden by createReactOnRails
79
+ // ===================================================================
80
+ obj.reactOnRailsPageLoaded = () => {
81
+ throw new Error('ReactOnRails.reactOnRailsPageLoaded is not initialized. This is a bug in react-on-rails.');
82
+ };
83
+ obj.reactOnRailsComponentLoaded = (domId) => {
84
+ void domId; // Mark as used
85
+ throw new Error('ReactOnRails.reactOnRailsComponentLoaded is not initialized. This is a bug in react-on-rails.');
225
86
  };
226
87
  // Cache the object and registries
227
88
  cachedObject = obj;
package/lib/base/full.js CHANGED
@@ -2,35 +2,17 @@
2
2
  * @deprecated Use `capabilities/ssr.ts` instead. This file is kept for backward compatibility
3
3
  * with older versions of react-on-rails-pro that import from `react-on-rails/@internal/base/full`.
4
4
  */
5
+ // This is a thin compatibility shim: the SSR implementations live in `capabilities/ssr.ts`
6
+ // (`createSSRCapability`). This file composes them onto the base client object, preserving
7
+ // the historical `createBaseFullObject` surface old Pro consumers rely on.
5
8
  import { createBaseClientObject } from "./client.js";
6
- import handleError from "../handleError.js";
7
- import serverRenderReactComponent from "../serverRenderReactComponent.js";
8
- import { buildLengthPrefixedResult } from "../serverRenderUtils.js";
9
- // Warn about bundle size when included in browser bundles
10
- if (typeof window !== 'undefined') {
11
- console.warn('Optimization opportunity: "react-on-rails" includes ~14KB of server-rendering code. ' +
12
- 'Browsers may not need it. See https://forum.shakacode.com/t/how-to-use-different-versions-of-a-file-for-client-and-server-rendering/1352 ' +
13
- '(Requires creating a free account). Click this for the stack trace.');
14
- }
9
+ import { createSSRCapability } from "../capabilities/ssr.js";
15
10
  export function createBaseFullObject(registries, currentObject = null) {
16
11
  // Get or create client object (with caching logic)
17
12
  const clientObject = createBaseClientObject(registries, currentObject);
18
- // Define SSR-specific functions with proper types
19
- // This object acts as a type-safe specification of what we're adding to the base object
20
- const reactOnRailsFullSpecificFunctions = {
21
- handleError(options) {
22
- return handleError(options);
23
- },
24
- serverRenderReactComponent(options) {
25
- return serverRenderReactComponent(options);
26
- },
27
- prepareRenderResult(html, consoleReplayScript, hasErrors, renderingError) {
28
- return buildLengthPrefixedResult(html, consoleReplayScript, {
29
- hasErrors,
30
- error: renderingError ?? undefined,
31
- });
32
- },
33
- };
13
+ // Delegate the SSR-specific functions to `createSSRCapability` (the canonical source).
14
+ // Typed to ReactOnRailsFullSpecificFunctions so we add exactly the SSR surface, nothing more.
15
+ const reactOnRailsFullSpecificFunctions = createSSRCapability();
34
16
  // Type assertion is safe here because:
35
17
  // 1. We start with BaseClientObjectType (from createBaseClientObject)
36
18
  // 2. We add exactly the methods defined in ReactOnRailsFullSpecificFunctions
@@ -2,23 +2,17 @@
2
2
  * @deprecated Use `capabilities/ssr.rsc.ts` instead. This file is kept for backward compatibility
3
3
  * with older versions of react-on-rails-pro that import from `react-on-rails/@internal/base/full`.
4
4
  */
5
+ // This is a thin compatibility shim: the RSC SSR stubs live in `capabilities/ssr.rsc.ts`
6
+ // (`createSSRCapability`). This file composes them onto the base client object, preserving
7
+ // the historical `createBaseFullObject` surface old Pro consumers rely on.
5
8
  import { createBaseClientObject } from "./client.js";
9
+ import { createSSRCapability } from "../capabilities/ssr.rsc.js";
6
10
  export function createBaseFullObject(registries, currentObject = null) {
7
11
  // Get or create client object (with caching logic)
8
12
  const clientObject = createBaseClientObject(registries, currentObject);
9
- // Define SSR-specific functions with proper types
10
- // This object acts as a type-safe specification of what we're adding to the base object
11
- const reactOnRailsFullSpecificFunctions = {
12
- handleError() {
13
- throw new Error('"handleError" function is not supported in RSC bundle');
14
- },
15
- serverRenderReactComponent() {
16
- throw new Error('"serverRenderReactComponent" function is not supported in RSC bundle');
17
- },
18
- prepareRenderResult() {
19
- throw new Error('"prepareRenderResult" function is not supported in RSC bundle');
20
- },
21
- };
13
+ // Delegate the RSC SSR stubs to `createSSRCapability` (the canonical source).
14
+ // Typed to ReactOnRailsFullSpecificFunctions so we add exactly the SSR surface, nothing more.
15
+ const reactOnRailsFullSpecificFunctions = createSSRCapability();
22
16
  // Type assertion is safe here because:
23
17
  // 1. We start with BaseClientObjectType (from createBaseClientObject)
24
18
  // 2. We add exactly the methods defined in ReactOnRailsFullSpecificFunctions
@@ -13,6 +13,7 @@ export interface Registries {
13
13
  getStoreGenerator: (name: string) => StoreGenerator;
14
14
  setStore: (name: string, store: Store) => void;
15
15
  clearHydratedStores: () => void;
16
+ clearStoreGenerators: () => void;
16
17
  storeGenerators: () => Map<string, StoreGenerator>;
17
18
  stores: () => Map<string, Store>;
18
19
  };
@@ -39,6 +40,7 @@ export declare function createCoreCapability(registries: Registries): {
39
40
  getStoreGenerator(name: string): StoreGenerator;
40
41
  setStore(name: string, store: Store): void;
41
42
  clearHydratedStores(): void;
43
+ clearStoreGenerators(): void;
42
44
  getComponent(name: string): RegisteredComponentEntry;
43
45
  registeredComponents(): Map<string, RegisteredComponentEntry>;
44
46
  storeGenerators(): Map<string, StoreGenerator>;
@@ -54,7 +54,6 @@ export function createCoreCapability(registries) {
54
54
  }
55
55
  }
56
56
  if (Object.prototype.hasOwnProperty.call(newOptions, 'rootErrorHandlers')) {
57
- // MUST SYNC: sibling implementation exists in packages/react-on-rails/src/base/client.ts.
58
57
  // Validates and merges the handlers per key (partial updates keep previously registered
59
58
  // callbacks); warns when the React runtime cannot support them. Store the merged result so
60
59
  // `option('rootErrorHandlers')` reflects the effective registration.
@@ -132,6 +131,9 @@ export function createCoreCapability(registries) {
132
131
  clearHydratedStores() {
133
132
  StoreRegistry.clearHydratedStores();
134
133
  },
134
+ clearStoreGenerators() {
135
+ StoreRegistry.clearStoreGenerators();
136
+ },
135
137
  getComponent(name) {
136
138
  return ComponentRegistry.get(name);
137
139
  },
@@ -0,0 +1,68 @@
1
+ export type RailsActionMethod = 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'post' | 'put' | 'patch' | 'delete';
2
+ export type RailsActionPath<TVariables> = string | ((variables: TVariables) => string);
3
+ export interface RailsActionOptions<TVariables> {
4
+ path: RailsActionPath<TVariables>;
5
+ method?: RailsActionMethod;
6
+ /**
7
+ * Maps variables to the JSON request body. Omit to send variables verbatim;
8
+ * supply `() => null` to send no body when variables only populate the path.
9
+ * DELETE requests never send a body; identify the resource in the URL instead.
10
+ */
11
+ body?: (variables: TVariables) => unknown;
12
+ /**
13
+ * Additional request headers. `X-CSRF-Token`, `X-Requested-With`, and the JSON-body
14
+ * `Content-Type` are controlled by this helper. `Accept` defaults to `application/json`
15
+ * but can be overridden here. When no JSON body is sent, `Content-Type` is removed after
16
+ * these headers are merged.
17
+ */
18
+ headers?: HeadersInit | ((variables: TVariables) => HeadersInit);
19
+ }
20
+ export interface RailsActionCallOptions {
21
+ headers?: HeadersInit;
22
+ signal?: AbortSignal;
23
+ }
24
+ export interface RailsActionMutationFunctionContext {
25
+ client: unknown;
26
+ meta: Record<string, unknown> | undefined;
27
+ mutationKey?: readonly unknown[];
28
+ }
29
+ export type RailsActionCallerOptions = RailsActionCallOptions | RailsActionMutationFunctionContext;
30
+ type RailsActionNoVariables = ReturnType<() => void>;
31
+ export type RailsActionCaller<TVariables, TResponse> = [TVariables] extends [RailsActionNoVariables] ? (variables?: RailsActionNoVariables, options?: RailsActionCallerOptions) => Promise<TResponse> : (variables: TVariables, options?: RailsActionCallerOptions) => Promise<TResponse>;
32
+ export declare class RailsActionRequestError<TResponseBody = unknown> extends Error {
33
+ readonly response: Response;
34
+ readonly responseBody: TResponseBody;
35
+ readonly cause?: unknown;
36
+ constructor(response: Response, responseBody: TResponseBody, options?: {
37
+ cause?: unknown;
38
+ });
39
+ }
40
+ /**
41
+ * Creates a CSRF-aware JSON caller for a Rails controller action.
42
+ *
43
+ * Supply the response generic from the generated Rails response declarations:
44
+ *
45
+ * ```ts
46
+ * type CreateProjectResponse = RailsResponseType<'projects.create'>;
47
+ * const createProject = createRailsAction<CreateProjectVariables, CreateProjectResponse>({
48
+ * path: '/api/projects',
49
+ * });
50
+ * ```
51
+ *
52
+ * The returned function is directly usable as a TanStack Query `mutationFn`.
53
+ * It always requests JSON, rejects browser-followed redirects, and resolves 204 or non-JSON success
54
+ * responses as `null`. Include `null` in `TResponse` when a successful empty response is expected.
55
+ * A 200 response with `text/html`, such as an unexpected Rails error page, also resolves as `null`.
56
+ * `options.headers` can override the default `Accept: application/json`; include `null` in `TResponse`
57
+ * when that custom Accept header may produce a successful non-JSON response.
58
+ * Omitting `body` sends `variables` as the JSON body verbatim; supply `body` to map or filter fields before
59
+ * serialization.
60
+ * Body values that would serialize lossy or outside JSON, including nested `undefined`, `BigInt`, and
61
+ * non-finite numbers, are rejected before `fetch` runs.
62
+ * When `variables` only populate `path`, supply `body: () => null` or a mapper to avoid forwarding them.
63
+ * Return `null` or `undefined` from `body` when the request should not send JSON. DELETE requests never
64
+ * send a JSON body; identify the resource in the URL instead.
65
+ */
66
+ export declare function createRailsAction<TVariables = undefined, TResponse = unknown>(options: RailsActionOptions<TVariables>): RailsActionCaller<TVariables, TResponse>;
67
+ export {};
68
+ //# sourceMappingURL=railsAction.d.ts.map
@@ -0,0 +1,373 @@
1
+ import { authenticityToken } from "./Authenticity.js";
2
+ export class RailsActionRequestError extends Error {
3
+ constructor(response, responseBody, options = {}) {
4
+ super(`Rails action request failed with status ${response.status}`);
5
+ this.name = 'RailsActionRequestError';
6
+ this.response = response;
7
+ this.responseBody = responseBody;
8
+ if (options.cause !== undefined) {
9
+ Object.defineProperty(this, 'cause', {
10
+ configurable: true,
11
+ value: options.cause,
12
+ writable: true,
13
+ });
14
+ }
15
+ }
16
+ }
17
+ const resolveSameOriginRequestUrl = (url) => {
18
+ try {
19
+ // Ignore document.baseURI so a page-level <base> tag cannot rewrite Rails action paths before the origin check.
20
+ const resolvedUrl = new URL(url, window.location.href);
21
+ if ((resolvedUrl.protocol === 'http:' || resolvedUrl.protocol === 'https:') &&
22
+ resolvedUrl.origin === window.location.origin) {
23
+ return resolvedUrl.href;
24
+ }
25
+ }
26
+ catch {
27
+ return null;
28
+ }
29
+ return null;
30
+ };
31
+ const JSON_CONTENT_TYPE_PATTERN = /^(application\/json|[^/]+\/[^;]+\+json)(?:\s*;|\s*$)/i;
32
+ const isJsonResponse = (response) => JSON_CONTENT_TYPE_PATTERN.test(response.headers.get('Content-Type') ?? '');
33
+ const isAbortError = (error) => typeof error === 'object' && error !== null && 'name' in error && error.name === 'AbortError';
34
+ const parseOptionalJsonBody = async (response) => {
35
+ if (!isJsonResponse(response)) {
36
+ return null;
37
+ }
38
+ try {
39
+ return (await response.json());
40
+ }
41
+ catch (error) {
42
+ if (!(error instanceof SyntaxError)) {
43
+ throw error;
44
+ }
45
+ return null;
46
+ }
47
+ };
48
+ const parseSuccessJsonBody = async (response) => {
49
+ if (response.status === 204 || response.status === 205) {
50
+ return null;
51
+ }
52
+ if (!isJsonResponse(response)) {
53
+ return null;
54
+ }
55
+ const responseText = await response.text();
56
+ return responseText.trim() === '' ? null : JSON.parse(responseText);
57
+ };
58
+ const warnOnPossibleRedirectFetchError = (fetchError) => {
59
+ if (process.env.NODE_ENV === 'production' || !(fetchError instanceof TypeError)) {
60
+ return;
61
+ }
62
+ if (!/failed to fetch|networkerror|load failed/i.test(fetchError.message)) {
63
+ return;
64
+ }
65
+ console.warn('[createRailsAction] The request may have failed because the server responded with a redirect or ' +
66
+ 'because the network is unavailable. createRailsAction requires JSON responses; Rails `redirect_to` ' +
67
+ 'is not supported for mutation endpoints.');
68
+ };
69
+ const warnOnDiscardedDeleteBody = (requestBody) => {
70
+ if (requestBody === undefined || requestBody === null) {
71
+ return false;
72
+ }
73
+ console.warn('[createRailsAction] A DELETE request resolved a JSON body that will not be sent. ' +
74
+ 'Use `body: () => null` when variables only populate the path, or identify the resource in the URL instead.');
75
+ return true;
76
+ };
77
+ const warnOnImplicitBodyWithDynamicPath = () => {
78
+ console.warn('[createRailsAction] A dynamic path with no `body` mapper sends all variables as the JSON body. ' +
79
+ 'Supply `body` to choose which fields are serialized, or `body: () => null` when variables only populate the path.');
80
+ };
81
+ const nonJsonBodyTypeName = (requestBody) => {
82
+ if (requestBody === undefined) {
83
+ return 'undefined';
84
+ }
85
+ if (typeof requestBody === 'number' && !Number.isFinite(requestBody)) {
86
+ return 'non-finite number';
87
+ }
88
+ if (typeof Date !== 'undefined' && requestBody instanceof Date && Number.isNaN(requestBody.getTime())) {
89
+ return 'invalid Date';
90
+ }
91
+ if (typeof Number !== 'undefined' &&
92
+ requestBody instanceof Number &&
93
+ !Number.isFinite(requestBody.valueOf())) {
94
+ return 'non-finite Number';
95
+ }
96
+ if (typeof FormData !== 'undefined' && requestBody instanceof FormData) {
97
+ return 'FormData';
98
+ }
99
+ if (typeof File !== 'undefined' && requestBody instanceof File) {
100
+ return 'File';
101
+ }
102
+ if (typeof Blob !== 'undefined' && requestBody instanceof Blob) {
103
+ return 'Blob';
104
+ }
105
+ if (typeof Headers !== 'undefined' && requestBody instanceof Headers) {
106
+ return 'Headers';
107
+ }
108
+ if (typeof Request !== 'undefined' && requestBody instanceof Request) {
109
+ return 'Request';
110
+ }
111
+ if (typeof Response !== 'undefined' && requestBody instanceof Response) {
112
+ return 'Response';
113
+ }
114
+ if (typeof URLSearchParams !== 'undefined' && requestBody instanceof URLSearchParams) {
115
+ return 'URLSearchParams';
116
+ }
117
+ if (typeof ArrayBuffer !== 'undefined' && requestBody instanceof ArrayBuffer) {
118
+ return 'ArrayBuffer';
119
+ }
120
+ if (typeof ArrayBuffer !== 'undefined' && ArrayBuffer.isView(requestBody)) {
121
+ return requestBody.constructor.name || 'ArrayBufferView';
122
+ }
123
+ if (typeof Map !== 'undefined' && requestBody instanceof Map) {
124
+ return 'Map';
125
+ }
126
+ if (typeof Set !== 'undefined' && requestBody instanceof Set) {
127
+ return 'Set';
128
+ }
129
+ if (typeof WeakMap !== 'undefined' && requestBody instanceof WeakMap) {
130
+ return 'WeakMap';
131
+ }
132
+ if (typeof WeakSet !== 'undefined' && requestBody instanceof WeakSet) {
133
+ return 'WeakSet';
134
+ }
135
+ if (typeof Error !== 'undefined' && requestBody instanceof Error) {
136
+ return requestBody.name || 'Error';
137
+ }
138
+ if (typeof RegExp !== 'undefined' && requestBody instanceof RegExp) {
139
+ return 'RegExp';
140
+ }
141
+ if (typeof requestBody === 'bigint') {
142
+ return 'BigInt';
143
+ }
144
+ if (typeof requestBody === 'function') {
145
+ return 'Function';
146
+ }
147
+ if (typeof requestBody === 'symbol') {
148
+ return 'Symbol';
149
+ }
150
+ if (typeof ReadableStream !== 'undefined' && requestBody instanceof ReadableStream) {
151
+ return 'ReadableStream';
152
+ }
153
+ if (typeof requestBody !== 'object' || requestBody === null) {
154
+ return null;
155
+ }
156
+ const maybeThenable = requestBody;
157
+ if (typeof maybeThenable.then === 'function') {
158
+ return 'Promise';
159
+ }
160
+ return null;
161
+ };
162
+ const preSerializationNonJsonBodyTypeName = (requestBody, activeObjects = new Set()) => {
163
+ const bodyTypeName = nonJsonBodyTypeName(requestBody);
164
+ if (bodyTypeName !== null) {
165
+ return bodyTypeName;
166
+ }
167
+ if (typeof requestBody !== 'object' || requestBody === null) {
168
+ return null;
169
+ }
170
+ const objectBody = requestBody;
171
+ if (typeof objectBody.toJSON === 'function') {
172
+ return null;
173
+ }
174
+ if (activeObjects.has(requestBody)) {
175
+ return 'circular object';
176
+ }
177
+ activeObjects.add(requestBody);
178
+ try {
179
+ const values = Array.isArray(requestBody)
180
+ ? requestBody
181
+ : Object.values(requestBody);
182
+ for (const value of values) {
183
+ const nestedBodyTypeName = preSerializationNonJsonBodyTypeName(value, activeObjects);
184
+ if (nestedBodyTypeName !== null) {
185
+ return nestedBodyTypeName;
186
+ }
187
+ }
188
+ return null;
189
+ }
190
+ finally {
191
+ activeObjects.delete(requestBody);
192
+ }
193
+ };
194
+ const jsonBodyTypeError = (bodyTypeName) => new TypeError(`[createRailsAction] The request body resolved to ${bodyTypeName}, which cannot be JSON serialized correctly. ` +
195
+ 'Return a plain JSON value, null, or undefined instead.');
196
+ const originalJsonBodyValue = (holder, key, rootBody) => {
197
+ if (key === '') {
198
+ return rootBody;
199
+ }
200
+ if (typeof holder !== 'object' || holder === null) {
201
+ return undefined;
202
+ }
203
+ return holder[key];
204
+ };
205
+ const stringifyJsonBody = (requestBody) => {
206
+ try {
207
+ const bodyTypeName = preSerializationNonJsonBodyTypeName(requestBody);
208
+ if (bodyTypeName !== null) {
209
+ throw jsonBodyTypeError(bodyTypeName);
210
+ }
211
+ const serializedBody = JSON.stringify(requestBody, function replaceJsonBodyValue(key, value) {
212
+ const originalValue = originalJsonBodyValue(this, key, requestBody);
213
+ const originalBodyTypeName = nonJsonBodyTypeName(originalValue);
214
+ if (originalBodyTypeName !== null) {
215
+ throw jsonBodyTypeError(originalBodyTypeName);
216
+ }
217
+ const replacedBodyTypeName = nonJsonBodyTypeName(value);
218
+ if (replacedBodyTypeName !== null) {
219
+ throw jsonBodyTypeError(replacedBodyTypeName);
220
+ }
221
+ return value;
222
+ });
223
+ if (serializedBody === undefined) {
224
+ throw jsonBodyTypeError('undefined');
225
+ }
226
+ return serializedBody;
227
+ }
228
+ catch (error) {
229
+ if (error instanceof TypeError && /BigInt/i.test(error.message)) {
230
+ throw new TypeError('[createRailsAction] The request body contains a BigInt value, which cannot be JSON serialized correctly. ' +
231
+ 'Convert BigInt values to strings or numbers before returning the body.');
232
+ }
233
+ throw error;
234
+ }
235
+ };
236
+ const mergeHeaders = (...headersList) => {
237
+ const headers = new Headers();
238
+ headersList.forEach((headersInit) => {
239
+ if (headersInit === undefined) {
240
+ return;
241
+ }
242
+ new Headers(headersInit).forEach((value, key) => {
243
+ headers.set(key, value);
244
+ });
245
+ });
246
+ return headers;
247
+ };
248
+ const buildRailsActionHeaders = (csrfToken, hasJsonBody, ...headersList) => {
249
+ const headers = mergeHeaders({ Accept: 'application/json' }, ...headersList);
250
+ if (hasJsonBody) {
251
+ headers.set('Content-Type', 'application/json');
252
+ }
253
+ else {
254
+ headers.delete('Content-Type');
255
+ }
256
+ headers.set('X-CSRF-Token', csrfToken);
257
+ headers.set('X-Requested-With', 'XMLHttpRequest');
258
+ return headers;
259
+ };
260
+ const resolvePath = (path, variables) => typeof path === 'function' ? path(variables) : path;
261
+ const resolveHeaders = (headers, variables) => (typeof headers === 'function' ? headers(variables) : headers);
262
+ const hasOwnProperty = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
263
+ function callOptionsValue(callOptions, key) {
264
+ if (typeof callOptions !== 'object' || callOptions === null) {
265
+ return undefined;
266
+ }
267
+ return hasOwnProperty(callOptions, key) ? callOptions[key] : undefined;
268
+ }
269
+ const assertBrowserContext = () => {
270
+ if (typeof window === 'undefined' || typeof document === 'undefined') {
271
+ throw new Error('createRailsAction can only be used in browser contexts.');
272
+ }
273
+ };
274
+ /**
275
+ * Creates a CSRF-aware JSON caller for a Rails controller action.
276
+ *
277
+ * Supply the response generic from the generated Rails response declarations:
278
+ *
279
+ * ```ts
280
+ * type CreateProjectResponse = RailsResponseType<'projects.create'>;
281
+ * const createProject = createRailsAction<CreateProjectVariables, CreateProjectResponse>({
282
+ * path: '/api/projects',
283
+ * });
284
+ * ```
285
+ *
286
+ * The returned function is directly usable as a TanStack Query `mutationFn`.
287
+ * It always requests JSON, rejects browser-followed redirects, and resolves 204 or non-JSON success
288
+ * responses as `null`. Include `null` in `TResponse` when a successful empty response is expected.
289
+ * A 200 response with `text/html`, such as an unexpected Rails error page, also resolves as `null`.
290
+ * `options.headers` can override the default `Accept: application/json`; include `null` in `TResponse`
291
+ * when that custom Accept header may produce a successful non-JSON response.
292
+ * Omitting `body` sends `variables` as the JSON body verbatim; supply `body` to map or filter fields before
293
+ * serialization.
294
+ * Body values that would serialize lossy or outside JSON, including nested `undefined`, `BigInt`, and
295
+ * non-finite numbers, are rejected before `fetch` runs.
296
+ * When `variables` only populate `path`, supply `body: () => null` or a mapper to avoid forwarding them.
297
+ * Return `null` or `undefined` from `body` when the request should not send JSON. DELETE requests never
298
+ * send a JSON body; identify the resource in the URL instead.
299
+ */
300
+ export function createRailsAction(options) {
301
+ const method = (options.method ?? 'POST').toUpperCase();
302
+ // DELETE body discard is an action configuration problem, so warn at most once per DELETE action factory.
303
+ let warnedOnDiscardedDeleteBody = method !== 'DELETE' || process.env.NODE_ENV === 'production';
304
+ let warnedOnImplicitDynamicPathBody = method === 'DELETE' ||
305
+ typeof options.path !== 'function' ||
306
+ options.body !== undefined ||
307
+ process.env.NODE_ENV === 'production';
308
+ const callRailsAction = async (variables, callOptions = {}) => {
309
+ // The public conditional type only permits omitted variables when TVariables is undefined.
310
+ const typedVariables = variables;
311
+ assertBrowserContext();
312
+ const resolvedPath = resolvePath(options.path, typedVariables);
313
+ const requestUrl = resolveSameOriginRequestUrl(resolvedPath);
314
+ if (requestUrl === null) {
315
+ throw new Error('createRailsAction can only call same-origin Rails action URLs. ' +
316
+ 'Ensure the path resolves to the same origin as the current page.');
317
+ }
318
+ const csrfToken = authenticityToken()?.trim();
319
+ if (!csrfToken) {
320
+ throw new Error('createRailsAction requires a <meta name="csrf-token"> tag before submitting. ' +
321
+ 'Add <%= csrf_meta_tags %> to your Rails layout.');
322
+ }
323
+ const requestBody = options.body !== undefined ? options.body(typedVariables) : typedVariables;
324
+ const hasJsonBody = method !== 'DELETE' && requestBody !== undefined && requestBody !== null;
325
+ const shouldWarnOnDiscardedDeleteBody = !warnedOnDiscardedDeleteBody && requestBody !== undefined && requestBody !== null;
326
+ const shouldWarnOnImplicitDynamicPathBody = !warnedOnImplicitDynamicPathBody && hasJsonBody;
327
+ const serializedRequestBody = hasJsonBody ? stringifyJsonBody(requestBody) : undefined;
328
+ if (shouldWarnOnDiscardedDeleteBody) {
329
+ warnOnDiscardedDeleteBody(requestBody);
330
+ warnedOnDiscardedDeleteBody = true;
331
+ }
332
+ if (shouldWarnOnImplicitDynamicPathBody) {
333
+ warnOnImplicitBodyWithDynamicPath();
334
+ warnedOnImplicitDynamicPathBody = true;
335
+ }
336
+ let response;
337
+ try {
338
+ response = await fetch(requestUrl, {
339
+ method,
340
+ mode: 'same-origin',
341
+ credentials: 'same-origin',
342
+ redirect: 'error',
343
+ signal: callOptionsValue(callOptions, 'signal'),
344
+ headers: buildRailsActionHeaders(csrfToken, hasJsonBody, resolveHeaders(options.headers, typedVariables), callOptionsValue(callOptions, 'headers')),
345
+ body: serializedRequestBody,
346
+ });
347
+ }
348
+ catch (fetchError) {
349
+ warnOnPossibleRedirectFetchError(fetchError);
350
+ throw fetchError;
351
+ }
352
+ if (!response.ok) {
353
+ // Keep this clone before any error-body reads so callers can still inspect the original response body.
354
+ let responseBody = null;
355
+ let bodyReadError;
356
+ try {
357
+ responseBody = await parseOptionalJsonBody(response.clone());
358
+ }
359
+ catch (error) {
360
+ if (isAbortError(error)) {
361
+ throw error;
362
+ }
363
+ bodyReadError = error;
364
+ responseBody = null;
365
+ }
366
+ throw new RailsActionRequestError(response, responseBody, { cause: bodyReadError });
367
+ }
368
+ const responseBody = await parseSuccessJsonBody(response);
369
+ return responseBody;
370
+ };
371
+ return callRailsAction;
372
+ }
373
+ //# sourceMappingURL=railsAction.js.map
@@ -30,6 +30,8 @@ export type RailsContext = {
30
30
  httpAcceptLanguage: string;
31
31
  rscPayloadGenerationUrlPath?: string;
32
32
  cspNonce?: string;
33
+ /** Omit or leave undefined to disable; only true is a valid opt-in value. */
34
+ rscStreamObservability?: true;
33
35
  } & ({
34
36
  serverSide: false;
35
37
  } | {
@@ -320,9 +322,10 @@ export interface ReactOnRails {
320
322
  /** @deprecated Use registerStoreGenerators instead */
321
323
  registerStore(stores: Record<string, StoreGenerator>): void;
322
324
  /**
323
- * Allows registration of store generators to be used by multiple React components on one Rails
324
- * view. Store generators are functions that take one arg, props, and return a store. Note that
325
- * the `setStore` API is different in that it's the actual store hydrated with props.
325
+ * Allows registration of store generators for legacy or advanced pages where multiple React roots
326
+ * on one Rails view share a Redux store. Store generators receive props and railsContext, then
327
+ * return a store. Note that the `setStore` API is different in that it's the actual store hydrated
328
+ * with props.
326
329
  * @param storeGenerators keys are store names, values are the store generators
327
330
  */
328
331
  registerStoreGenerators(storeGenerators: Record<string, StoreGenerator>): void;
@@ -408,6 +411,11 @@ export interface ReactOnRailsInternal extends ReactOnRails {
408
411
  * request.
409
412
  */
410
413
  clearHydratedStores(): void;
414
+ /**
415
+ * Clears registered store generators. Used by internal tests and setup code that must reset global
416
+ * registration state between runs.
417
+ */
418
+ clearStoreGenerators(): void;
411
419
  /**
412
420
  * @example
413
421
  * ```js
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-on-rails",
3
- "version": "17.0.0-rc.6",
3
+ "version": "17.0.0-rc.7",
4
4
  "description": "react-on-rails JavaScript for react_on_rails Ruby gem",
5
5
  "main": "lib/ReactOnRails.full.js",
6
6
  "type": "module",
@@ -29,6 +29,10 @@
29
29
  "./pageLifecycle": "./lib/pageLifecycle.js",
30
30
  "./createReactOutput": "./lib/createReactOutput.js",
31
31
  "./isServerRenderResult": "./lib/isServerRenderResult.js",
32
+ "./railsAction": {
33
+ "types": "./lib/railsAction.d.ts",
34
+ "default": "./lib/railsAction.js"
35
+ },
32
36
  "./reactApis": "./lib/reactApis.cjs",
33
37
  "./webpackHelpers": "./lib/webpackHelpers.cjs",
34
38
  "./reactHydrateOrRender": "./lib/reactHydrateOrRender.js",