Skip to content

Commit 6aa8e2c

Browse files
Add component registry timeout configuration
- Introduce a new configuration option `component_registry_timeout` to control the maximum time to wait for client-side component registration - Update `CallbackRegistry` to handle timeout events and reject pending callbacks - Add validation for the timeout configuration in the Rails configuration - Modify client-side startup and page lifecycle to support the new timeout mechanism - Enhance error handling for unregistered components and stores
1 parent c02fe4c commit 6aa8e2c

File tree

7 files changed

+109
-85
lines changed

7 files changed

+109
-85
lines changed

lib/react_on_rails/configuration.rb

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ def self.configure
1010

1111
DEFAULT_GENERATED_ASSETS_DIR = File.join(%w[public webpack], Rails.env).freeze
1212
DEFAULT_REACT_CLIENT_MANIFEST_FILE = "react-client-manifest.json"
13+
DEFAULT_COMPONENT_REGISTRY_TIMEOUT = 5000
1314

1415
def self.configuration
1516
@configuration ||= Configuration.new(
@@ -44,7 +45,11 @@ def self.configuration
4445
make_generated_server_bundle_the_entrypoint: false,
4546
defer_generated_component_packs: true,
4647
# forces the loading of React components
47-
force_load: false
48+
force_load: false,
49+
# Maximum time in milliseconds to wait for client-side component registration after page load.
50+
# If exceeded, an error will be thrown for server-side rendered components not registered on the client.
51+
# Set to 0 to disable the timeout and wait indefinitely for component registration.
52+
component_registry_timeout: DEFAULT_COMPONENT_REGISTRY_TIMEOUT
4853
)
4954
end
5055

@@ -60,7 +65,7 @@ class Configuration
6065
:same_bundle_for_client_and_server, :rendering_props_extension,
6166
:make_generated_server_bundle_the_entrypoint,
6267
:defer_generated_component_packs, :force_load, :rsc_bundle_js_file,
63-
:react_client_manifest_file
68+
:react_client_manifest_file, :component_registry_timeout
6469

6570
# rubocop:disable Metrics/AbcSize
6671
def initialize(node_modules_location: nil, server_bundle_js_file: nil, prerender: nil,
@@ -76,7 +81,7 @@ def initialize(node_modules_location: nil, server_bundle_js_file: nil, prerender
7681
i18n_dir: nil, i18n_yml_dir: nil, i18n_output_format: nil, i18n_yml_safe_load_options: nil,
7782
random_dom_id: nil, server_render_method: nil, rendering_props_extension: nil,
7883
components_subdirectory: nil, auto_load_bundle: nil, force_load: nil,
79-
rsc_bundle_js_file: nil, react_client_manifest_file: nil)
84+
rsc_bundle_js_file: nil, react_client_manifest_file: nil, component_registry_timeout: nil)
8085
self.node_modules_location = node_modules_location.present? ? node_modules_location : Rails.root
8186
self.generated_assets_dirs = generated_assets_dirs
8287
self.generated_assets_dir = generated_assets_dir
@@ -100,6 +105,7 @@ def initialize(node_modules_location: nil, server_bundle_js_file: nil, prerender
100105
self.raise_on_prerender_error = raise_on_prerender_error
101106
self.skip_display_none = skip_display_none
102107
self.rendering_props_extension = rendering_props_extension
108+
self.component_registry_timeout = component_registry_timeout
103109

104110
# Server rendering:
105111
self.server_bundle_js_file = server_bundle_js_file
@@ -132,10 +138,19 @@ def setup_config_values
132138
error_if_using_packer_and_generated_assets_dir_not_match_public_output_path
133139
# check_deprecated_settings
134140
adjust_precompile_task
141+
check_component_registry_timeout
135142
end
136143

137144
private
138145

146+
def check_component_registry_timeout
147+
self.component_registry_timeout = DEFAULT_COMPONENT_REGISTRY_TIMEOUT if component_registry_timeout.nil?
148+
149+
return if component_registry_timeout.is_a?(Integer) && component_registry_timeout >= 0
150+
151+
raise ReactOnRails::Error, "component_registry_timeout must be a positive integer"
152+
end
153+
139154
def check_autobundling_requirements
140155
raise_missing_components_subdirectory if auto_load_bundle && !components_subdirectory.present?
141156
return unless components_subdirectory.present?

lib/react_on_rails/helper.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,7 @@ def rails_context(server_side: true)
309309
# ALERT: Keep in sync with node_package/src/types/index.ts for the properties of RailsContext
310310
@rails_context ||= begin
311311
result = {
312+
componentRegistryTimeout: ReactOnRails.configuration.component_registry_timeout,
312313
railsEnv: Rails.env,
313314
inMailer: in_mailer?,
314315
# Locale settings
Lines changed: 70 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,65 @@
11
import { ItemRegistrationCallback } from "./types";
2+
import { onPageLoaded, onPageUnloaded } from "./pageLifecycle";
3+
import { getContextAndRailsContext } from "./context";
24

35
export default class CallbackRegistry<T> {
6+
private readonly registryType: string;
47
private registeredItems = new Map<string, T>();
5-
private callbacks = new Map<string, Array<ItemRegistrationCallback<T>>>();
8+
private callbacks = new Map<string, Array<{
9+
resolve: ItemRegistrationCallback<T>;
10+
reject: (error: Error) => void;
11+
}>>();
12+
13+
private timoutEventsInitialized = false;
14+
private timedout = false;
15+
16+
constructor(registryType: string) {
17+
this.registryType = registryType;
18+
this.initializeTimeoutEvents();
19+
}
20+
21+
private initializeTimeoutEvents() {
22+
if (!this.timoutEventsInitialized) {
23+
this.timoutEventsInitialized = true;
24+
}
25+
26+
let timeoutId: NodeJS.Timeout;
27+
const triggerTimeout = () => {
28+
this.timedout = true;
29+
this.callbacks.forEach((itemCallbacks, itemName) => {
30+
itemCallbacks.forEach((callback) => {
31+
callback.reject(this.createNotFoundError(itemName));
32+
});
33+
});
34+
};
35+
36+
onPageLoaded(() => {
37+
const registryTimeout = getContextAndRailsContext().railsContext?.componentRegistryTimeout;
38+
if (!registryTimeout) return;
39+
40+
timeoutId = setTimeout(triggerTimeout, registryTimeout);
41+
});
42+
43+
onPageUnloaded(() => {
44+
this.callbacks.clear();
45+
this.timedout = false;
46+
clearTimeout(timeoutId);
47+
});
48+
}
649

750
set(name: string, item: T): void {
851
this.registeredItems.set(name, item);
952

1053
const callbacks = this.callbacks.get(name) || [];
11-
callbacks.forEach(callback => {
12-
setTimeout(() => callback(item), 0);
13-
});
54+
callbacks.forEach(callback => callback.resolve(item));
1455
this.callbacks.delete(name);
1556
}
1657

17-
get(name: string): T | undefined {
18-
return this.registeredItems.get(name);
58+
get(name: string): T {
59+
const item = this.registeredItems.get(name);
60+
if (item !== undefined) return item;
61+
62+
throw this.createNotFoundError(name);
1963
}
2064

2165
has(name: string): boolean {
@@ -30,21 +74,28 @@ export default class CallbackRegistry<T> {
3074
return this.registeredItems;
3175
}
3276

33-
onItemRegistered(name: string, callback: ItemRegistrationCallback<T>): void {
34-
const existingItem = this.registeredItems.get(name);
35-
if (existingItem) {
36-
setTimeout(() => callback(existingItem), 0);
37-
return;
38-
}
77+
getOrWaitForItem(name: string): Promise<T> {
78+
return new Promise((resolve, reject) => {
79+
try {
80+
resolve(this.get(name));
81+
} catch(error) {
82+
if (this.timedout) {
83+
throw error;
84+
}
3985

40-
const itemCallbacks = this.callbacks.get(name) || [];
41-
itemCallbacks.push(callback);
42-
this.callbacks.set(name, itemCallbacks);
86+
const itemCallbacks = this.callbacks.get(name) || [];
87+
itemCallbacks.push({ resolve, reject });
88+
this.callbacks.set(name, itemCallbacks);
89+
}
90+
});
4391
}
4492

45-
getOrWaitForItem(name: string): Promise<T> {
46-
return new Promise((resolve) => {
47-
this.onItemRegistered(name, resolve);
48-
});
93+
private createNotFoundError(itemName: string): Error {
94+
const keys = Array.from(this.registeredItems.keys()).join(', ');
95+
return new Error(
96+
`Could not find ${this.registryType} registered with name ${itemName}. ` +
97+
`Registered ${this.registryType} names include [ ${keys} ]. ` +
98+
`Maybe you forgot to register the ${this.registryType}?`
99+
);
49100
}
50101
}

node_package/src/ComponentRegistry.ts

Lines changed: 2 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,26 +2,13 @@ import {
22
type RegisteredComponent,
33
type ReactComponentOrRenderFunction,
44
type RenderFunction,
5-
type ItemRegistrationCallback,
65
} from './types';
76
import isRenderFunction from './isRenderFunction';
87
import CallbackRegistry from './CallbackRegistry';
98

10-
const componentRegistry = new CallbackRegistry<RegisteredComponent>();
9+
const componentRegistry = new CallbackRegistry<RegisteredComponent>('component');
1110

1211
export default {
13-
/**
14-
* Register a callback to be called when a specific component is registered
15-
* @param componentName Name of the component to watch for
16-
* @param callback Function called with the component details when registered
17-
*/
18-
onComponentRegistered(
19-
componentName: string,
20-
callback: ItemRegistrationCallback<RegisteredComponent>,
21-
): void {
22-
componentRegistry.onItemRegistered(componentName, callback);
23-
},
24-
2512
/**
2613
* @param components { component1: component1, component2: component2, etc. }
2714
*/
@@ -53,12 +40,7 @@ export default {
5340
* @returns { name, component, isRenderFunction, isRenderer }
5441
*/
5542
get(name: string): RegisteredComponent {
56-
const component = componentRegistry.get(name);
57-
if (component !== undefined) return component;
58-
59-
const keys = Array.from(componentRegistry.getAll().keys()).join(', ');
60-
throw new Error(`Could not find component registered with name ${name}. \
61-
Registered component names include [ ${keys} ]. Maybe you forgot to register the component?`);
43+
return componentRegistry.get(name);
6244
},
6345

6446
getOrWaitForComponent(name: string): Promise<RegisteredComponent> {

node_package/src/StoreRegistry.ts

Lines changed: 15 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import CallbackRegistry from './CallbackRegistry';
2-
import type { Store, StoreGenerator, ItemRegistrationCallback } from './types';
2+
import type { Store, StoreGenerator } from './types';
33

4-
const storeGeneratorRegistry = new CallbackRegistry<StoreGenerator>();
5-
const hydratedStoreRegistry = new CallbackRegistry<Store>();
4+
const storeGeneratorRegistry = new CallbackRegistry<StoreGenerator>('store generator');
5+
const hydratedStoreRegistry = new CallbackRegistry<Store>('hydrated store');
66

77
export default {
88
/**
@@ -33,28 +33,24 @@ export default {
3333
* @returns Redux Store, possibly hydrated
3434
*/
3535
getStore(name: string, throwIfMissing = true): Store | undefined {
36-
const store = hydratedStoreRegistry.get(name);
37-
if (store) return store;
38-
39-
const storeKeys = Array.from(hydratedStoreRegistry.getAll().keys()).join(', ');
40-
41-
if (storeKeys.length === 0) {
42-
const msg =
36+
try {
37+
return hydratedStoreRegistry.get(name);
38+
} catch (error) {
39+
if (hydratedStoreRegistry.getAll().size === 0) {
40+
const msg =
4341
`There are no stores hydrated and you are requesting the store ${name}.
4442
This can happen if you are server rendering and either:
4543
1. You do not call redux_store near the top of your controller action's view (not the layout)
4644
and before any call to react_component.
4745
2. You do not render redux_store_hydration_data anywhere on your page.`;
48-
throw new Error(msg);
49-
}
46+
throw new Error(msg);
47+
}
5048

51-
if (throwIfMissing) {
52-
console.log('storeKeys', storeKeys);
53-
throw new Error(`Could not find hydrated store with name '${name}'. ` +
54-
`Hydrated store names include [${storeKeys}].`);
49+
if (throwIfMissing) {
50+
throw error;
51+
}
52+
return undefined;
5553
}
56-
57-
return undefined;
5854
},
5955

6056
/**
@@ -63,12 +59,7 @@ This can happen if you are server rendering and either:
6359
* @returns storeCreator with given name
6460
*/
6561
getStoreGenerator(name: string): StoreGenerator {
66-
const generator = storeGeneratorRegistry.get(name);
67-
if (generator) return generator;
68-
69-
const storeKeys = Array.from(storeGeneratorRegistry.getAll().keys()).join(', ');
70-
throw new Error(`Could not find store registered with name '${name}'. Registered store ` +
71-
`names include [ ${storeKeys} ]. Maybe you forgot to register the store?`);
62+
return storeGeneratorRegistry.get(name);
7263
},
7364

7465
/**
@@ -103,15 +94,6 @@ This can happen if you are server rendering and either:
10394
return hydratedStoreRegistry.getAll();
10495
},
10596

106-
/**
107-
* Register a callback to be called when a specific store is hydrated
108-
* @param storeName Name of the store to watch for
109-
* @param callback Function called with the store when hydrated
110-
*/
111-
onStoreHydrated(storeName: string, callback: ItemRegistrationCallback<Store>): void {
112-
hydratedStoreRegistry.onItemRegistered(storeName, callback);
113-
},
114-
11597
/**
11698
* Used by components to get the hydrated store, waiting for it to be hydrated if necessary.
11799
* @param name Name of the store to wait for
@@ -121,15 +103,6 @@ This can happen if you are server rendering and either:
121103
return hydratedStoreRegistry.getOrWaitForItem(name);
122104
},
123105

124-
/**
125-
* Register a callback to be called when a specific store generator is registered
126-
* @param storeName Name of the store generator to watch for
127-
* @param callback Function called with the store generator when registered
128-
*/
129-
onStoreGeneratorRegistered(storeName: string, callback: ItemRegistrationCallback<StoreGenerator>): void {
130-
storeGeneratorRegistry.onItemRegistered(storeName, callback);
131-
},
132-
133106
/**
134107
* Used by components to get the store generator, waiting for it to be registered if necessary.
135108
* @param name Name of the store generator to wait for

node_package/src/clientStartup.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,6 @@ export async function clientStartup(context: Context): Promise<void> {
4141
hydrateForceLoadedStores();
4242

4343
// Other components and stores are rendered and hydrated when the page is fully loaded
44-
onPageLoaded(renderOrHydrateAllComponents);
44+
onPageLoaded(reactOnRailsPageLoaded);
4545
onPageUnloaded(reactOnRailsPageUnloaded);
4646
}

node_package/src/pageLifecycle.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ function setupTurbolinksEventListeners(): void {
6363

6464
let isEventListenerInitialized = false;
6565
function initializePageEventListeners(): void {
66+
if (typeof window === 'undefined') return;
67+
6668
if (isEventListenerInitialized) {
6769
return;
6870
}

0 commit comments

Comments
 (0)