Skip to content

Commit

Permalink
hydrate the component immediately when loaded and registered
Browse files Browse the repository at this point in the history
  • Loading branch information
AbanoubGhadban committed Nov 18, 2024
1 parent d975912 commit 7d6577c
Show file tree
Hide file tree
Showing 5 changed files with 117 additions and 33 deletions.
25 changes: 19 additions & 6 deletions lib/react_on_rails/helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -435,7 +435,7 @@ def build_react_component_result_for_server_rendered_string(

result_console_script = render_options.replay_console ? console_script : ""
result = compose_react_component_html_with_spec_and_console(
component_specification_tag, rendered_output, result_console_script
component_specification_tag, rendered_output, result_console_script, render_options.dom_id
)

prepend_render_rails_context(result)
Expand Down Expand Up @@ -501,12 +501,19 @@ def build_react_component_result_for_server_rendered_hash(
)
end

def compose_react_component_html_with_spec_and_console(component_specification_tag, rendered_output, console_script)
def compose_react_component_html_with_spec_and_console(component_specification_tag, rendered_output, console_script, dom_id = nil)
hydrate_script = dom_id.present? ? content_tag(:script, %(
window.REACT_ON_RAILS_PENDING_COMPONENT_DOM_IDS.push('#{dom_id}');
if (window.ReactOnRails) {
window.ReactOnRails.renderOrHydrateLoadedComponents();
}
).html_safe) : ""
# IMPORTANT: Ensure that we mark string as html_safe to avoid escaping.
html_content = <<~HTML
#{rendered_output}
#{component_specification_tag}
#{console_script}
#{hydrate_script}
HTML
html_content.strip.html_safe
end
Expand All @@ -518,10 +525,15 @@ def rails_context_if_not_already_rendered

@rendered_rails_context = true

content_tag(:script,
json_safe_and_pretty(data).html_safe,
type: "application/json",
id: "js-react-on-rails-context")
rails_context_tag = content_tag(:script,
json_safe_and_pretty(data).html_safe,
type: "application/json",
id: "js-react-on-rails-context")
rails_context_tag.concat(
content_tag(:script, %(
window.REACT_ON_RAILS_PENDING_COMPONENT_DOM_IDS = [];
).html_safe)
)
end

# prepend the rails_context if not yet applied
Expand Down Expand Up @@ -553,6 +565,7 @@ def internal_react_component(react_component_name, options = {})
json_safe_and_pretty(render_options.client_props).html_safe,
type: "application/json",
class: "js-react-on-rails-component",
id: "js-react-on-rails-component-#{render_options.dom_id}",
"data-component-name" => render_options.react_component_name,
"data-trace" => (render_options.trace ? true : nil),
"data-dom-id" => render_options.dom_id)
Expand Down
38 changes: 37 additions & 1 deletion node_package/src/ComponentRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,31 @@ import type { RegisteredComponent, ReactComponentOrRenderFunction, RenderFunctio
import isRenderFunction from './isRenderFunction';

const registeredComponents = new Map<string, RegisteredComponent>();
const registrationCallbacks = new Map<string, Array<(component: RegisteredComponent) => void>>();

export default {
/**
* Register a callback to be called when a specific component is registered
* @param componentName Name of the component to watch for
* @param callback Function called with the component details when registered
*/
onComponentRegistered(
componentName: string,
callback: (component: RegisteredComponent) => void
): void {
// If component is already registered, schedule callback
const existingComponent = registeredComponents.get(componentName);
if (existingComponent) {
setTimeout(() => callback(existingComponent), 0);
return;
}

// Store callback for future registration
const callbacks = registrationCallbacks.get(componentName) || [];
callbacks.push(callback);
registrationCallbacks.set(componentName, callbacks);
},

/**
* @param components { component1: component1, component2: component2, etc. }
*/
Expand All @@ -21,12 +44,19 @@ export default {
const renderFunction = isRenderFunction(component);
const isRenderer = renderFunction && (component as RenderFunction).length === 3;

registeredComponents.set(name, {
const registeredComponent = {
name,
component,
renderFunction,
isRenderer,
};
registeredComponents.set(name, registeredComponent);

const callbacks = registrationCallbacks.get(name) || [];
callbacks.forEach(callback => {
setTimeout(() => callback(registeredComponent), 0);
});
registrationCallbacks.delete(name);
});
},

Expand All @@ -45,6 +75,12 @@ export default {
Registered component names include [ ${keys} ]. Maybe you forgot to register the component?`);
},

async getOrWaitForComponent(name: string): Promise<RegisteredComponent> {
return new Promise((resolve) => {
this.onComponentRegistered(name, resolve);
});
},

/**
* Get a Map containing all registered components. Useful for debugging.
* @returns Map where key is the component name and values are the
Expand Down
13 changes: 13 additions & 0 deletions node_package/src/ReactOnRails.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,10 @@ ctx.ReactOnRails = {
ClientStartup.reactOnRailsPageLoaded();
},

renderOrHydrateLoadedComponents(): void {
ClientStartup.renderOrHydrateLoadedComponents();
},

reactOnRailsComponentLoaded(domId: string): void {
ClientStartup.reactOnRailsComponentLoaded(domId);
},
Expand Down Expand Up @@ -240,6 +244,15 @@ ctx.ReactOnRails = {
return ComponentRegistry.get(name);
},

/**
* Get the component that you registered, or wait for it to be registered
* @param name
* @returns {name, component, renderFunction, isRenderer}
*/
getOrWaitForComponent(name: string): Promise<RegisteredComponent> {
return ComponentRegistry.getOrWaitForComponent(name);
},

/**
* Used by server rendering by Rails
* @param options
Expand Down
72 changes: 46 additions & 26 deletions node_package/src/clientStartup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,15 @@ declare global {
ReactOnRails: ReactOnRailsType;
__REACT_ON_RAILS_EVENT_HANDLERS_RAN_ONCE__?: boolean;
roots: Root[];
REACT_ON_RAILS_PENDING_COMPONENT_DOM_IDS?: string[];
REACT_ON_RAILS_UNMOUNTED_BEFORE?: boolean;
}

namespace NodeJS {
interface Global {
ReactOnRails: ReactOnRailsType;
roots: Root[];
REACT_ON_RAILS_PENDING_COMPONENT_DOM_IDS?: string[];
}
}
namespace Turbolinks {
Expand Down Expand Up @@ -134,7 +137,7 @@ function domNodeIdForEl(el: Element): string {
* Used for client rendering by ReactOnRails. Either calls ReactDOM.hydrate, ReactDOM.render, or
* delegates to a renderer registered by the user.
*/
function render(el: Element, context: Context, railsContext: RailsContext): void {
async function render(el: Element, context: Context, railsContext: RailsContext): Promise<void> {
// This must match lib/react_on_rails/helper.rb
const name = el.getAttribute('data-component-name') || '';
const domNodeId = domNodeIdForEl(el);
Expand All @@ -144,7 +147,7 @@ function render(el: Element, context: Context, railsContext: RailsContext): void
try {
const domNode = document.getElementById(domNodeId);
if (domNode) {
const componentObj = context.ReactOnRails.getComponent(name);
const componentObj = await context.ReactOnRails.getOrWaitForComponent(name);
if (delegateToRenderer(componentObj, props, railsContext, domNodeId, trace)) {
return;
}
Expand Down Expand Up @@ -180,13 +183,6 @@ You should return a React.Component always for the client side entry point.`);
}
}

function forEachReactOnRailsComponentRender(context: Context, railsContext: RailsContext): void {
const els = reactOnRailsHtmlElements();
for (let i = 0; i < els.length; i += 1) {
render(els[i], context, railsContext);
}
}

function parseRailsContext(): RailsContext | null {
const el = document.getElementById('js-react-on-rails-context');
if (!el) {
Expand All @@ -202,39 +198,62 @@ function parseRailsContext(): RailsContext | null {
return JSON.parse(el.textContent);
}

function getContextAndRailsContext(): { context: Context; railsContext: RailsContext | null } {
const railsContext = parseRailsContext();
const context = findContext();

if (railsContext && supportsRootApi && !context.roots) {
context.roots = [];
}

return { context, railsContext };
}

export function reactOnRailsPageLoaded(): void {
debugTurbolinks('reactOnRailsPageLoaded');

const railsContext = parseRailsContext();

const { context, railsContext } = getContextAndRailsContext();
// If no react on rails components
if (!railsContext) return;

const context = findContext();
if (supportsRootApi) {
context.roots = [];
}
forEachStore(context, railsContext);
forEachReactOnRailsComponentRender(context, railsContext);
}

export function reactOnRailsComponentLoaded(domId: string): void {
debugTurbolinks(`reactOnRailsComponentLoaded ${domId}`);
async function renderUsingDomId(domId: string, context: Context, railsContext: RailsContext) {
const el = document.querySelector(`[data-dom-id=${domId}]`);
if (!el) return;

const railsContext = parseRailsContext();
await render(el, context, railsContext);
}

export async function renderOrHydrateLoadedComponents(): Promise<void> {
debugTurbolinks('renderOrHydrateLoadedComponents');

const { context, railsContext } = getContextAndRailsContext();

// If no react on rails components
if (!railsContext) return;

const context = findContext();
if (supportsRootApi) {
context.roots = [];
}
// copy and clear the pending dom ids, so they don't get processed again
const pendingDomIds = context.REACT_ON_RAILS_PENDING_COMPONENT_DOM_IDS ?? [];
context.REACT_ON_RAILS_PENDING_COMPONENT_DOM_IDS = [];
await Promise.all(
pendingDomIds.map(async (domId) => {
await renderUsingDomId(domId, context, railsContext);
})
);
}

const el = document.querySelector(`[data-dom-id=${domId}]`);
if (!el) return;
export async function reactOnRailsComponentLoaded(domId: string): Promise<void> {
debugTurbolinks(`reactOnRailsComponentLoaded ${domId}`);

const { context, railsContext } = getContextAndRailsContext();

// If no react on rails components
if (!railsContext) return;

render(el, context, railsContext);
await renderUsingDomId(domId, context, railsContext);
}

function unmount(el: Element): void {
Expand Down Expand Up @@ -333,5 +352,6 @@ export function clientStartup(context: Context): void {
// eslint-disable-next-line no-underscore-dangle, no-param-reassign
context.__REACT_ON_RAILS_EVENT_HANDLERS_RAN_ONCE__ = true;

console.log('clientStartup');
onPageReady(renderInit);
}
2 changes: 2 additions & 0 deletions node_package/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ export interface ReactOnRails {
setOptions(newOptions: {traceTurbolinks: boolean}): void;
reactHydrateOrRender(domNode: Element, reactElement: ReactElement, hydrate: boolean): RenderReturnType;
reactOnRailsPageLoaded(): void;
renderOrHydrateLoadedComponents(): void;
reactOnRailsComponentLoaded(domId: string): void;
authenticityToken(): string | null;
authenticityHeaders(otherHeaders: { [id: string]: string }): AuthenticityHeaders;
Expand All @@ -169,6 +170,7 @@ export interface ReactOnRails {
name: string, props: Record<string, string>, domNodeId: string, hydrate: boolean
): RenderReturnType;
getComponent(name: string): RegisteredComponent;
getOrWaitForComponent(name: string): Promise<RegisteredComponent>;
serverRenderReactComponent(options: RenderParams): null | string | Promise<RenderResult>;
streamServerRenderedReactComponent(options: RenderParams): Readable;
serverRenderRSCReactComponent(options: RenderParams): PassThrough;
Expand Down

0 comments on commit 7d6577c

Please sign in to comment.