Skip to content

Commit c02fe4c

Browse files
Refactor client-side rendering and page lifecycle management
- Extract context and page lifecycle utilities into separate modules - Improve handling of Turbolinks and Turbo events - Simplify client startup and rendering process - Add more robust page load and unload event management - Rename and reorganize context-related functions
1 parent a5f13fc commit c02fe4c

File tree

7 files changed

+186
-127
lines changed

7 files changed

+186
-127
lines changed

node_package/src/CallbackRegistry.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,9 @@ export default class CallbackRegistry<T> {
3737
return;
3838
}
3939

40-
const callbacks = this.callbacks.get(name) || [];
41-
callbacks.push(callback);
42-
this.callbacks.set(name, callbacks);
40+
const itemCallbacks = this.callbacks.get(name) || [];
41+
itemCallbacks.push(callback);
42+
this.callbacks.set(name, itemCallbacks);
4343
}
4444

4545
getOrWaitForItem(name: string): Promise<T> {

node_package/src/ClientSideRenderer.ts

Lines changed: 2 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import type {
77
Root,
88
} from './types';
99

10-
import { reactOnRailsContext, type Context } from './context';
10+
import { getContextAndRailsContext, resetContextAndRailsContext, type Context } from './context';
1111
import createReactOutput from './createReactOutput';
1212
import { isServerRenderHash } from './isServerRenderResult';
1313
import reactHydrateOrRender from './reactHydrateOrRender';
@@ -39,35 +39,6 @@ DELEGATING TO RENDERER ${name} for dom node with id: ${domNodeId} with props, ra
3939
}
4040

4141
const getDomId = (domIdOrElement: string | Element): string => typeof domIdOrElement === 'string' ? domIdOrElement : domIdOrElement.getAttribute('data-dom-id') || '';
42-
43-
let currentContext: Context | null = null;
44-
let currentRailsContext: RailsContext | null = null;
45-
46-
// caches context and railsContext to avoid re-parsing rails-context each time a component is rendered
47-
// Cached values will be reset when unmountAll() is called
48-
function getContextAndRailsContext(): { context: Context | null; railsContext: RailsContext | null } {
49-
// Return cached values if already set
50-
if (currentContext && currentRailsContext) {
51-
return { context: currentContext, railsContext: currentRailsContext };
52-
}
53-
54-
currentContext = reactOnRailsContext();
55-
56-
const el = document.getElementById('js-react-on-rails-context');
57-
if (!el || !el.textContent) {
58-
return { context: null, railsContext: null };
59-
}
60-
61-
try {
62-
currentRailsContext = JSON.parse(el.textContent);
63-
} catch (e) {
64-
console.error('Error parsing rails context:', e);
65-
return { context: null, railsContext: null };
66-
}
67-
68-
return { context: currentContext, railsContext: currentRailsContext };
69-
}
70-
7142
class ComponentRenderer {
7243
private domNodeId: string;
7344
private state: 'unmounted' | 'rendering' | 'rendered';
@@ -237,7 +208,6 @@ export function renderOrHydrateComponent(domIdOrElement: string | Element): Comp
237208
return root;
238209
}
239210

240-
241211
export function renderOrHydrateForceLoadedComponents(): void {
242212
const els = document.querySelectorAll(`.js-react-on-rails-component[data-force-load="true"]`);
243213
els.forEach((el) => renderOrHydrateComponent(el));
@@ -251,8 +221,7 @@ export function renderOrHydrateAllComponents(): void {
251221
function unmountAllComponents(): void {
252222
renderedRoots.forEach((root) => root.unmount());
253223
renderedRoots.clear();
254-
currentContext = null;
255-
currentRailsContext = null;
224+
resetContextAndRailsContext();
256225
}
257226

258227
const storeRenderers = new Map<string, StoreRenderer>();

node_package/src/clientStartup.ts

Lines changed: 11 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -1,53 +1,13 @@
1-
import { reactOnRailsContext, type Context } from './context';
1+
import { type Context, isWindow } from './context';
22
import {
33
renderOrHydrateForceLoadedComponents,
44
renderOrHydrateAllComponents,
55
hydrateForceLoadedStores,
66
hydrateAllStores,
77
unmountAll,
88
} from './ClientSideRenderer';
9-
10-
/* eslint-disable @typescript-eslint/no-explicit-any */
11-
12-
declare global {
13-
namespace Turbolinks {
14-
interface TurbolinksStatic {
15-
controller?: unknown;
16-
}
17-
}
18-
}
19-
20-
21-
function debugTurbolinks(...msg: string[]): void {
22-
if (!window) {
23-
return;
24-
}
25-
26-
const context = reactOnRailsContext();
27-
if (context.ReactOnRails && context.ReactOnRails.option('traceTurbolinks')) {
28-
console.log('TURBO:', ...msg);
29-
}
30-
}
31-
32-
function turbolinksInstalled(): boolean {
33-
return (typeof Turbolinks !== 'undefined');
34-
}
35-
36-
function turboInstalled() {
37-
const context = reactOnRailsContext();
38-
if (context.ReactOnRails) {
39-
return context.ReactOnRails.option('turbo') === true;
40-
}
41-
return false;
42-
}
43-
44-
function turbolinksVersion5(): boolean {
45-
return (typeof Turbolinks.controller !== 'undefined');
46-
}
47-
48-
function turbolinksSupported(): boolean {
49-
return Turbolinks.supported;
50-
}
9+
import { onPageLoaded, onPageUnloaded } from './pageLifecycle';
10+
import { debugTurbolinks } from './turbolinksUtils';
5111

5212
export function reactOnRailsPageLoaded(): void {
5313
debugTurbolinks('reactOnRailsPageLoaded');
@@ -60,44 +20,8 @@ function reactOnRailsPageUnloaded(): void {
6020
unmountAll();
6121
}
6222

63-
function renderInit(): void {
64-
// Install listeners when running on the client (browser).
65-
// We must do this check for turbolinks AFTER the document is loaded because we load the
66-
// Webpack bundles first.
67-
if ((!turbolinksInstalled() || !turbolinksSupported()) && !turboInstalled()) {
68-
debugTurbolinks('NOT USING TURBOLINKS: calling reactOnRailsPageLoaded');
69-
reactOnRailsPageLoaded();
70-
return;
71-
}
72-
73-
if (turboInstalled()) {
74-
debugTurbolinks(
75-
'USING TURBO: document added event listeners ' +
76-
'turbo:before-render and turbo:render.');
77-
document.addEventListener('turbo:before-render', reactOnRailsPageUnloaded);
78-
document.addEventListener('turbo:render', reactOnRailsPageLoaded);
79-
reactOnRailsPageLoaded();
80-
} else if (turbolinksVersion5()) {
81-
debugTurbolinks(
82-
'USING TURBOLINKS 5: document added event listeners ' +
83-
'turbolinks:before-render and turbolinks:render.');
84-
document.addEventListener('turbolinks:before-render', reactOnRailsPageUnloaded);
85-
document.addEventListener('turbolinks:render', reactOnRailsPageLoaded);
86-
reactOnRailsPageLoaded();
87-
} else {
88-
debugTurbolinks(
89-
'USING TURBOLINKS 2: document added event listeners page:before-unload and ' +
90-
'page:change.');
91-
document.addEventListener('page:before-unload', reactOnRailsPageUnloaded);
92-
document.addEventListener('page:change', reactOnRailsPageLoaded);
93-
}
94-
}
95-
96-
function isWindow(context: Context): context is Window {
97-
return (context as Window).document !== undefined;
98-
}
99-
100-
export function clientStartup(context: Context): void {
23+
export async function clientStartup(context: Context): Promise<void> {
24+
await new Promise((resolve) => setTimeout(resolve, 4000));
10125
// Check if server rendering
10226
if (!isWindow(context)) {
10327
return;
@@ -112,14 +36,11 @@ export function clientStartup(context: Context): void {
11236
// eslint-disable-next-line no-underscore-dangle, no-param-reassign
11337
context.__REACT_ON_RAILS_EVENT_HANDLERS_RAN_ONCE__ = true;
11438

115-
if (document.readyState !== 'complete') {
116-
// force loaded components and stores are rendered and hydrated immediately
117-
renderOrHydrateForceLoadedComponents();
118-
hydrateForceLoadedStores();
39+
// force loaded components and stores are rendered and hydrated immediately
40+
renderOrHydrateForceLoadedComponents();
41+
hydrateForceLoadedStores();
11942

120-
// Other components and stores are rendered and hydrated when the page is fully loaded
121-
document.addEventListener('DOMContentLoaded', renderInit);
122-
} else {
123-
renderInit();
124-
}
43+
// Other components and stores are rendered and hydrated when the page is fully loaded
44+
onPageLoaded(renderOrHydrateAllComponents);
45+
onPageUnloaded(reactOnRailsPageUnloaded);
12546
}

node_package/src/context.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { ReactOnRails as ReactOnRailsType } from './types';
1+
import type { ReactOnRails as ReactOnRailsType, RailsContext } from './types';
22

33
declare global {
44
interface Window {
@@ -24,6 +24,9 @@ export default function context(this: void): Context | void {
2424
this;
2525
}
2626

27+
export function isWindow(ctx: Context): ctx is Window {
28+
return (ctx as Window).document !== undefined;
29+
}
2730

2831
export function reactOnRailsContext(): Context {
2932
const ctx = context();
@@ -32,3 +35,36 @@ export function reactOnRailsContext(): Context {
3235
}
3336
return ctx;
3437
}
38+
39+
let currentContext: Context | null = null;
40+
let currentRailsContext: RailsContext | null = null;
41+
42+
// caches context and railsContext to avoid re-parsing rails-context each time a component is rendered
43+
// Cached values will be reset when resetContextAndRailsContext() is called
44+
export function getContextAndRailsContext(): { context: Context | null; railsContext: RailsContext | null } {
45+
// Return cached values if already set
46+
if (currentContext && currentRailsContext) {
47+
return { context: currentContext, railsContext: currentRailsContext };
48+
}
49+
50+
currentContext = reactOnRailsContext();
51+
52+
const el = document.getElementById('js-react-on-rails-context');
53+
if (!el || !el.textContent) {
54+
return { context: null, railsContext: null };
55+
}
56+
57+
try {
58+
currentRailsContext = JSON.parse(el.textContent);
59+
} catch (e) {
60+
console.error('Error parsing rails context:', e);
61+
return { context: null, railsContext: null };
62+
}
63+
64+
return { context: currentContext, railsContext: currentRailsContext };
65+
}
66+
67+
export function resetContextAndRailsContext(): void {
68+
currentContext = null;
69+
currentRailsContext = null;
70+
}

node_package/src/pageLifecycle.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import {
2+
debugTurbolinks,
3+
turbolinksInstalled,
4+
turbolinksSupported,
5+
turboInstalled,
6+
turbolinksVersion5,
7+
} from './turbolinksUtils';
8+
9+
type PageLifecycleCallback = () => void;
10+
enum PageState {
11+
Load = 'load',
12+
Unload = 'unload',
13+
Initial = 0
14+
}
15+
16+
const pageLoadedCallbacks = new Set<PageLifecycleCallback>();
17+
const pageUnloadedCallbacks = new Set<PageLifecycleCallback>();
18+
19+
let currentPageState: PageState = PageState.Initial;
20+
21+
function runPageLoadedCallbacks(): void {
22+
currentPageState = PageState.Load;
23+
pageLoadedCallbacks.forEach((callback) => callback());
24+
}
25+
26+
function runPageUnloadedCallbacks(): void {
27+
currentPageState = PageState.Unload;
28+
pageUnloadedCallbacks.forEach((callback) => callback());
29+
}
30+
31+
function setupTurbolinksEventListeners(): void {
32+
// Install listeners when running on the client (browser).
33+
// We must do this check for turbolinks AFTER the document is loaded because we load the
34+
// Webpack bundles first.
35+
if ((!turbolinksInstalled() || !turbolinksSupported()) && !turboInstalled()) {
36+
debugTurbolinks('NOT USING TURBOLINKS: calling reactOnRailsPageLoaded');
37+
runPageLoadedCallbacks();
38+
return;
39+
}
40+
41+
if (turboInstalled()) {
42+
debugTurbolinks(
43+
'USING TURBO: document added event listeners ' +
44+
'turbo:before-render and turbo:render.');
45+
document.addEventListener('turbo:before-render', runPageUnloadedCallbacks);
46+
document.addEventListener('turbo:render', runPageLoadedCallbacks);
47+
runPageLoadedCallbacks();
48+
} else if (turbolinksVersion5()) {
49+
debugTurbolinks(
50+
'USING TURBOLINKS 5: document added event listeners ' +
51+
'turbolinks:before-render and turbolinks:render.');
52+
document.addEventListener('turbolinks:before-render', runPageUnloadedCallbacks);
53+
document.addEventListener('turbolinks:render', runPageLoadedCallbacks);
54+
runPageLoadedCallbacks();
55+
} else {
56+
debugTurbolinks(
57+
'USING TURBOLINKS 2: document added event listeners page:before-unload and ' +
58+
'page:change.');
59+
document.addEventListener('page:before-unload', runPageUnloadedCallbacks);
60+
document.addEventListener('page:change', runPageLoadedCallbacks);
61+
}
62+
}
63+
64+
let isEventListenerInitialized = false;
65+
function initializePageEventListeners(): void {
66+
if (isEventListenerInitialized) {
67+
return;
68+
}
69+
isEventListenerInitialized = true;
70+
71+
if (document.readyState === 'complete') {
72+
setupTurbolinksEventListeners();
73+
} else {
74+
document.addEventListener('DOMContentLoaded', setupTurbolinksEventListeners);
75+
}
76+
}
77+
78+
export function onPageLoaded(callback: PageLifecycleCallback): void {
79+
if (currentPageState === PageState.Load) {
80+
callback();
81+
}
82+
pageLoadedCallbacks.add(callback);
83+
initializePageEventListeners();
84+
}
85+
86+
export function onPageUnloaded(callback: PageLifecycleCallback): void {
87+
if (currentPageState === PageState.Unload) {
88+
callback();
89+
}
90+
pageUnloadedCallbacks.add(callback);
91+
initializePageEventListeners();
92+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { reactOnRailsContext } from './context';
2+
3+
declare global {
4+
namespace Turbolinks {
5+
interface TurbolinksStatic {
6+
controller?: unknown;
7+
}
8+
}
9+
}
10+
11+
export function debugTurbolinks(...msg: string[]): void {
12+
if (!window) {
13+
return;
14+
}
15+
16+
const context = reactOnRailsContext();
17+
if (context.ReactOnRails && context.ReactOnRails.option('traceTurbolinks')) {
18+
console.log('TURBO:', ...msg);
19+
}
20+
}
21+
22+
export function turbolinksInstalled(): boolean {
23+
return (typeof Turbolinks !== 'undefined');
24+
}
25+
26+
export function turboInstalled() {
27+
const context = reactOnRailsContext();
28+
if (context.ReactOnRails) {
29+
return context.ReactOnRails.option('turbo') === true;
30+
}
31+
return false;
32+
}
33+
34+
export function turbolinksVersion5(): boolean {
35+
return (typeof Turbolinks.controller !== 'undefined');
36+
}
37+
38+
export function turbolinksSupported(): boolean {
39+
return Turbolinks.supported;
40+
}

node_package/src/types/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ type ReactComponent = ComponentType<any> | string;
1414

1515
// Keep these in sync with method lib/react_on_rails/helper.rb#rails_context
1616
export interface RailsContext {
17+
componentRegistryTimeout: number;
1718
railsEnv: string;
1819
inMailer: boolean;
1920
i18nLocale: string;

0 commit comments

Comments
 (0)