diff --git a/CHANGELOG.md b/CHANGELOG.md index eac41e24e6..42d0ef1b5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/release-notes/15.0.0.md b/docs/release-notes/15.0.0.md index 50722bcfde..8133cc377e 100644 --- a/docs/release-notes/15.0.0.md +++ b/docs/release-notes/15.0.0.md @@ -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: diff --git a/node_package/src/CallbackRegistry.ts b/node_package/src/CallbackRegistry.ts index 3858c52d10..35b3fd85dc 100644 --- a/node_package/src/CallbackRegistry.ts +++ b/node_package/src/CallbackRegistry.ts @@ -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, @@ -47,7 +47,7 @@ export default class CallbackRegistry { }; onPageLoaded(() => { - const registryTimeout = getContextAndRailsContext().railsContext?.componentRegistryTimeout; + const registryTimeout = getRailsContext()?.componentRegistryTimeout; if (!registryTimeout) return; timeoutId = setTimeout(triggerTimeout, registryTimeout); diff --git a/node_package/src/ClientSideRenderer.ts b/node_package/src/ClientSideRenderer.ts index 8d14405e0b..cc0b31d212 100644 --- a/node_package/src/ClientSideRenderer.ts +++ b/node_package/src/ClientSideRenderer.ts @@ -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'; @@ -61,15 +63,15 @@ 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); }); } @@ -77,7 +79,7 @@ class ComponentRenderer { * 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 { + private async render(el: Element, railsContext: RailsContext): Promise { // This must match lib/react_on_rails/helper.rb const name = el.getAttribute('data-component-name') || ''; const { domNodeId } = this; @@ -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; } @@ -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; } @@ -191,22 +193,17 @@ class StoreRenderer { storeDataElement.textContent !== null ? (JSON.parse(storeDataElement.textContent) as Record) : {}; - 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, - ) { - const storeGenerator = await context.ReactOnRails.getOrWaitForStoreGenerator(name); + private async hydrate(railsContext: RailsContext, name: string, props: Record) { + 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'; } @@ -252,7 +249,7 @@ export const renderOrHydrateAllComponents = () => function unmountAllComponents(): void { renderedRoots.forEach((root) => root.unmount()); renderedRoots.clear(); - resetContextAndRailsContext(); + resetRailsContext(); } const storeRenderers = new Map(); diff --git a/node_package/src/ReactOnRails.client.ts b/node_package/src/ReactOnRails.client.ts index 1923002284..da163cd7d3 100644 --- a/node_package/src/ReactOnRails.client.ts +++ b/node_package/src/ReactOnRails.client.ts @@ -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, @@ -19,19 +18,11 @@ 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 = { @@ -39,7 +30,7 @@ const DEFAULT_OPTIONS = { turbo: false, }; -ctx.ReactOnRails = { +globalThis.ReactOnRails = { options: {}, register(components: Record): void { @@ -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; diff --git a/node_package/src/clientStartup.ts b/node_package/src/clientStartup.ts index b733c75d17..7e4c0b7197 100644 --- a/node_package/src/clientStartup.ts +++ b/node_package/src/clientStartup.ts @@ -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'; @@ -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, diff --git a/node_package/src/context.ts b/node_package/src/context.ts index 1ce2f49a51..93918f8e84 100644 --- a/node_package/src/context.ts +++ b/node_package/src/context.ts @@ -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; } diff --git a/node_package/src/turbolinksUtils.ts b/node_package/src/turbolinksUtils.ts index b5dd0dc0a6..4ec70415bf 100644 --- a/node_package/src/turbolinksUtils.ts +++ b/node_package/src/turbolinksUtils.ts @@ -1,5 +1,3 @@ -import { reactOnRailsContext } from './context'; - declare global { namespace Turbolinks { interface TurbolinksStatic { @@ -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); } } @@ -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 { diff --git a/node_package/tests/ComponentRegistry.test.js b/node_package/tests/ComponentRegistry.test.js index 066c2ef262..07df713283 100644 --- a/node_package/tests/ComponentRegistry.test.js +++ b/node_package/tests/ComponentRegistry.test.js @@ -23,7 +23,7 @@ jest.mock('../src/pageLifecycle', () => ({ })); jest.mock('../src/context', () => ({ - getContextAndRailsContext: () => ({ railsContext: { componentRegistryTimeout: 100 } }), + getRailsContext: () => ({ componentRegistryTimeout: 100 }), })); describe('ComponentRegistry', () => {