Skip to content

Replace context with globalThis #1727

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Apr 10, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ Changes since the last non-beta release.

- Deprecated `defer_generated_component_packs` configuration option. You should use `generated_component_packs_loading_strategy` instead. [PR 1712](https://github.com/shakacode/react_on_rails/pull/1712) by [AbanoubGhadban](https://github.com/AbanoubGhadban).

### Changed

- The global context is now accessed using `globalThis`. [PR 1727](https://github.com/shakacode/react_on_rails/pull/1727) by [alexeyr-ci2](https://github.com/alexeyr-ci2).

### [15.0.0-alpha.2] - 2025-03-07

See [Release Notes](docs/release-notes/15.0.0.md) for full details.
Expand Down
5 changes: 5 additions & 0 deletions docs/release-notes/15.0.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@ Major improvements to component and store hydration:
- If you were previously using `defer_generated_component_packs: false`, use `generated_component_packs_loading_strategy: :sync` instead
- For optimal performance with Shakapacker ≥ 8.2.0, consider using `generated_component_packs_loading_strategy: :async`

### `globalThis`

[`globalThis`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/globalThis) is now used in code.
It should be available in browsers since 2020 and in Node, but in case your environment doesn't support it, you'll need to shim it using [globalthis](https://www.npmjs.com/package/globalthis) or [core-js](https://www.npmjs.com/package/core-js).

## Store Dependencies for Components

When using Redux stores with multiple components, you need to explicitly declare store dependencies to optimize hydration. Here's how:
Expand Down
4 changes: 2 additions & 2 deletions node_package/src/CallbackRegistry.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ItemRegistrationCallback } from './types';
import { onPageLoaded, onPageUnloaded } from './pageLifecycle';
import { getContextAndRailsContext } from './context';
import { getRailsContext } from './context';

/**
* Represents information about a registered item including its value,
Expand Down Expand Up @@ -47,7 +47,7 @@ export default class CallbackRegistry<T> {
};

onPageLoaded(() => {
const registryTimeout = getContextAndRailsContext().railsContext?.componentRegistryTimeout;
const registryTimeout = getRailsContext()?.componentRegistryTimeout;
if (!registryTimeout) return;

timeoutId = setTimeout(triggerTimeout, registryTimeout);
Expand Down
35 changes: 16 additions & 19 deletions node_package/src/ClientSideRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ import * as ReactDOM from 'react-dom';
import type { ReactElement } from 'react';
import type { RailsContext, RegisteredComponent, RenderFunction, Root } from './types';

import { getContextAndRailsContext, resetContextAndRailsContext, type Context } from './context';
import { getRailsContext, resetRailsContext } from './context';
import createReactOutput from './createReactOutput';
import { isServerRenderHash } from './isServerRenderResult';
import reactHydrateOrRender from './reactHydrateOrRender';
import { supportsRootApi } from './reactApis';
import { debugTurbolinks } from './turbolinksUtils';
import * as StoreRegistry from './StoreRegistry';
import * as ComponentRegistry from './ComponentRegistry';

const REACT_ON_RAILS_STORE_ATTRIBUTE = 'data-js-react-on-rails-store';

Expand Down Expand Up @@ -61,23 +63,23 @@ class ComponentRenderer {
const storeDependencies = el.getAttribute('data-store-dependencies');
const storeDependenciesArray = storeDependencies ? (JSON.parse(storeDependencies) as string[]) : [];

const { context, railsContext } = getContextAndRailsContext();
if (!context || !railsContext) return;
const railsContext = getRailsContext();
if (!railsContext) return;

// Wait for all store dependencies to be loaded
this.renderPromise = Promise.all(
storeDependenciesArray.map((storeName) => context.ReactOnRails.getOrWaitForStore(storeName)),
storeDependenciesArray.map((storeName) => StoreRegistry.getOrWaitForStore(storeName)),
).then(() => {
if (this.state === 'unmounted') return Promise.resolve();
return this.render(el, context, railsContext);
return this.render(el, railsContext);
});
}

/**
* Used for client rendering by ReactOnRails. Either calls ReactDOM.hydrate, ReactDOM.render, or
* delegates to a renderer registered by the user.
*/
private async render(el: Element, context: Context, railsContext: RailsContext): Promise<void> {
private async render(el: Element, railsContext: RailsContext): Promise<void> {
// This must match lib/react_on_rails/helper.rb
const name = el.getAttribute('data-component-name') || '';
const { domNodeId } = this;
Expand All @@ -87,7 +89,7 @@ class ComponentRenderer {
try {
const domNode = document.getElementById(domNodeId);
if (domNode) {
const componentObj = await context.ReactOnRails.getOrWaitForComponent(name);
const componentObj = await ComponentRegistry.getOrWaitForComponent(name);
if (this.state === 'unmounted') {
return;
}
Expand Down Expand Up @@ -181,8 +183,8 @@ class StoreRenderer {

constructor(storeDataElement: Element) {
this.state = 'hydrating';
const { context, railsContext } = getContextAndRailsContext();
if (!context || !railsContext) {
const railsContext = getRailsContext();
if (!railsContext) {
return;
}

Expand All @@ -191,22 +193,17 @@ class StoreRenderer {
storeDataElement.textContent !== null
? (JSON.parse(storeDataElement.textContent) as Record<string, unknown>)
: {};
this.hydratePromise = this.hydrate(context, railsContext, name, props);
this.hydratePromise = this.hydrate(railsContext, name, props);
}

private async hydrate(
context: Context,
railsContext: RailsContext,
name: string,
props: Record<string, unknown>,
) {
const storeGenerator = await context.ReactOnRails.getOrWaitForStoreGenerator(name);
private async hydrate(railsContext: RailsContext, name: string, props: Record<string, unknown>) {
const storeGenerator = await StoreRegistry.getOrWaitForStoreGenerator(name);
if (this.state === 'unmounted') {
return;
}

const store = storeGenerator(props, railsContext);
context.ReactOnRails.setStore(name, store);
StoreRegistry.setStore(name, store);
this.state = 'hydrated';
}

Expand Down Expand Up @@ -252,7 +249,7 @@ export const renderOrHydrateAllComponents = () =>
function unmountAllComponents(): void {
renderedRoots.forEach((root) => root.unmount());
renderedRoots.clear();
resetContextAndRailsContext();
resetRailsContext();
}

const storeRenderers = new Map<string, StoreRenderer>();
Expand Down
21 changes: 6 additions & 15 deletions node_package/src/ReactOnRails.client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import * as StoreRegistry from './StoreRegistry';
import buildConsoleReplay from './buildConsoleReplay';
import createReactOutput from './createReactOutput';
import * as Authenticity from './Authenticity';
import context from './context';
import type {
RegisteredComponent,
RenderResult,
Expand All @@ -19,27 +18,19 @@ import type {
} from './types';
import reactHydrateOrRender from './reactHydrateOrRender';

const ctx = context();

if (ctx === undefined) {
throw new Error("The context (usually Window or NodeJS's Global) is undefined.");
}

if (ctx.ReactOnRails !== undefined) {
/* eslint-disable @typescript-eslint/no-base-to-string -- Window and Global both have useful toString() */
if (globalThis.ReactOnRails !== undefined) {
throw new Error(`\
The ReactOnRails value exists in the ${ctx} scope, it may not be safe to overwrite it.
The ReactOnRails value exists in the ${globalThis} scope, it may not be safe to overwrite it.
This could be caused by setting Webpack's optimization.runtimeChunk to "true" or "multiple," rather than "single."
Check your Webpack configuration. Read more at https://github.com/shakacode/react_on_rails/issues/1558.`);
/* eslint-enable @typescript-eslint/no-base-to-string */
}

const DEFAULT_OPTIONS = {
traceTurbolinks: false,
turbo: false,
};

ctx.ReactOnRails = {
globalThis.ReactOnRails = {
options: {},

register(components: Record<string, ReactComponentOrRenderFunction>): void {
Expand Down Expand Up @@ -199,9 +190,9 @@ ctx.ReactOnRails = {
},
};

ctx.ReactOnRails.resetOptions();
globalThis.ReactOnRails.resetOptions();

ClientStartup.clientStartup(ctx);
ClientStartup.clientStartup();

export * from './types';
export default ctx.ReactOnRails;
export default globalThis.ReactOnRails;
15 changes: 7 additions & 8 deletions node_package/src/clientStartup.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { type Context, isWindow } from './context';
import {
renderOrHydrateForceLoadedComponents,
renderOrHydrateAllComponents,
hydrateForceLoadedStores,
hydrateAllStores,
hydrateForceLoadedStores,
renderOrHydrateAllComponents,
renderOrHydrateForceLoadedComponents,
unmountAll,
} from './ClientSideRenderer';
import { onPageLoaded, onPageUnloaded } from './pageLifecycle';
Expand All @@ -19,20 +18,20 @@ function reactOnRailsPageUnloaded(): void {
unmountAll();
}

export function clientStartup(context: Context) {
export function clientStartup() {
// Check if server rendering
if (!isWindow(context)) {
if (globalThis.document === undefined) {
return;
}

// Tried with a file local variable, but the install handler gets called twice.
// eslint-disable-next-line no-underscore-dangle
if (context.__REACT_ON_RAILS_EVENT_HANDLERS_RAN_ONCE__) {
if (globalThis.__REACT_ON_RAILS_EVENT_HANDLERS_RAN_ONCE__) {
return;
}

// eslint-disable-next-line no-underscore-dangle
context.__REACT_ON_RAILS_EVENT_HANDLERS_RAN_ONCE__ = true;
globalThis.__REACT_ON_RAILS_EVENT_HANDLERS_RAN_ONCE__ = true;

// Force loaded components and stores are rendered and hydrated immediately.
// The hydration process can handle the concurrent hydration of components and stores,
Expand Down
61 changes: 14 additions & 47 deletions node_package/src/context.ts
Original file line number Diff line number Diff line change
@@ -1,69 +1,36 @@
import type { ReactOnRailsInternal as ReactOnRailsType, RailsContext } from './types';
import type { ReactOnRailsInternal, RailsContext } from './types';

declare global {
interface Window {
ReactOnRails: ReactOnRailsType;
__REACT_ON_RAILS_EVENT_HANDLERS_RAN_ONCE__?: boolean;
}

namespace globalThis {
/* eslint-disable no-var,vars-on-top */
var ReactOnRails: ReactOnRailsType;
/* eslint-enable no-var,vars-on-top */
}
/* eslint-disable no-var,vars-on-top,no-underscore-dangle */
var ReactOnRails: ReactOnRailsInternal;
var __REACT_ON_RAILS_EVENT_HANDLERS_RAN_ONCE__: boolean;
/* eslint-enable no-var,vars-on-top,no-underscore-dangle */
}

export type Context = Window | typeof globalThis;

/**
* Get the context, be it window or global
*/
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
export default function context(this: void): Context | void {
return (typeof window !== 'undefined' && window) || (typeof global !== 'undefined' && global) || this;
}

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

export function reactOnRailsContext(): Context {
const ctx = context();
if (ctx === undefined || typeof ctx.ReactOnRails === 'undefined') {
throw new Error('ReactOnRails is undefined in both global and window namespaces.');
}
return ctx;
}

let currentContext: Context | null = null;
let currentRailsContext: RailsContext | null = null;

// caches context and railsContext to avoid re-parsing rails-context each time a component is rendered
// Cached values will be reset when resetContextAndRailsContext() is called
export function getContextAndRailsContext(): { context: Context | null; railsContext: RailsContext | null } {
// Cached values will be reset when resetRailsContext() is called
export function getRailsContext(): RailsContext | null {
// Return cached values if already set
if (currentContext && currentRailsContext) {
return { context: currentContext, railsContext: currentRailsContext };
if (currentRailsContext) {
return currentRailsContext;
}

currentContext = reactOnRailsContext();

const el = document.getElementById('js-react-on-rails-context');
if (!el || !el.textContent) {
return { context: null, railsContext: null };
if (!el?.textContent) {
return null;
}

try {
currentRailsContext = JSON.parse(el.textContent) as RailsContext;
return currentRailsContext;
} catch (e) {
console.error('Error parsing Rails context:', e);
return { context: null, railsContext: null };
return null;
}

return { context: currentContext, railsContext: currentRailsContext };
}

export function resetContextAndRailsContext(): void {
currentContext = null;
export function resetRailsContext(): void {
currentRailsContext = null;
}
11 changes: 2 additions & 9 deletions node_package/src/turbolinksUtils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { reactOnRailsContext } from './context';

declare global {
namespace Turbolinks {
interface TurbolinksStatic {
Expand All @@ -18,8 +16,7 @@ export function debugTurbolinks(...msg: unknown[]): void {
return;
}

const context = reactOnRailsContext();
if (context.ReactOnRails?.option('traceTurbolinks')) {
if (globalThis.ReactOnRails?.option('traceTurbolinks')) {
console.log('TURBO:', ...msg);
}
}
Expand All @@ -29,11 +26,7 @@ export function turbolinksInstalled(): boolean {
}

export function turboInstalled() {
const context = reactOnRailsContext();
if (context.ReactOnRails) {
return context.ReactOnRails.option('turbo') === true;
}
return false;
return globalThis.ReactOnRails?.option('turbo') === true;
}

export function turbolinksVersion5(): boolean {
Expand Down
2 changes: 1 addition & 1 deletion node_package/tests/ComponentRegistry.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ jest.mock('../src/pageLifecycle', () => ({
}));

jest.mock('../src/context', () => ({
getContextAndRailsContext: () => ({ railsContext: { componentRegistryTimeout: 100 } }),
getRailsContext: () => ({ componentRegistryTimeout: 100 }),
}));

describe('ComponentRegistry', () => {
Expand Down
Loading