Skip to content

Refactor: Replace stub-throw + Object.assign pattern with capability-based composition for ReactOnRails JS object #2904

@AbanoubGhadban

Description

@AbanoubGhadban

Summary

Replace the current 5-stage Object.assign mutation pattern for building the ReactOnRails global object with a single-step capability-based composition pattern.

Problem

The ReactOnRails JS global object is currently built through 5 mutation stages:

  1. createBaseClientObject / createBaseFullObject → base methods + stub methods that throw
  2. createReactOnRails → overwrites lifecycle stubs, adds Pro stubs that throw "requires Pro"
  3. createReactOnRailsPro → overwrites Pro stubs with real implementations, has timing-dependent checks
  4. ReactOnRails.node.ts → raw property mutation: ReactOnRails.streamServerRenderedReactComponent = ...
  5. ReactOnRailsRSC.ts → raw property mutation: ReactOnRails.serverRenderRSCReactComponent = ...

This creates several issues:

  • Core package knows about Pro features: createReactOnRails.ts defines stubs for getOrWaitForComponent, streamServerRenderedReactComponent, etc. Adding a new Pro feature requires modifying the core package.
  • Timing-dependent initialization: createReactOnRailsPro.ts:146-154 must check "did Stage 4 already add streamServerRenderedReactComponent?" before overwriting.
  • Type safety bypass: Both factory functions use as unknown as ReactOnRailsInternal because TypeScript can't track mutations via Object.assign.
  • Redundant factory-of-factories pattern: baseObjectCreator parameter adds indirection — the difference between "client" and "full" bundles could just be whether the SSR capability is included.

Proposed Solution

1. Decompose ReactOnRailsInternal into tier interfaces

interface ReactOnRailsCore { register, getStore, authenticityToken, ... }
interface ReactOnRailsLifecycle { reactOnRailsPageLoaded, reactOnRailsComponentLoaded }
interface ReactOnRailsSSR { serverRenderReactComponent, handleError }
interface ReactOnRailsProFeatures { getOrWaitForComponent, getOrWaitForStore, ... }
interface ReactOnRailsStreaming { streamServerRenderedReactComponent }
interface ReactOnRailsRSC { serverRenderRSCReactComponent }

// Backward compat
type ReactOnRailsInternal = ReactOnRailsCore & ReactOnRailsLifecycle & ReactOnRailsSSR
  & ReactOnRailsProFeatures & ReactOnRailsStreaming & ReactOnRailsRSC;

2. Define capability objects typed against their tier

export const ssrCapability: ReactOnRailsSSR = {
  serverRenderReactComponent(options) { ... },
  handleError(options) { ... },
};

TypeScript validates each capability at definition time.

3. Single-step composition via createReactOnRails

type Capability = Partial<ReactOnRailsInternal>;

function createReactOnRails<T>(capabilities: Capability[]): T {
  const obj = {} as T;
  for (const cap of capabilities) Object.assign(obj, cap);
  if (!globalThis.ReactOnRails) globalThis.ReactOnRails = obj;
  return obj;
}

4. Entry points declare their exact type

// Client bundle
type ClientBundle = ReactOnRailsCore & ReactOnRailsLifecycle;
export default createReactOnRails<ClientBundle>([coreCapability, lifecycleCapability]);

// Pro Node bundle
type ProNodeBundle = ReactOnRailsCore & ReactOnRailsLifecycle & ReactOnRailsSSR
  & ReactOnRailsProFeatures & ReactOnRailsStreaming;
export default createReactOnRails<ProNodeBundle>([
  coreCapability, ssrCapability, proFeaturesCapability, proLifecycleCapability, streamingCapability,
]);

Benefits

  • No stub-throw pattern: If Pro isn't installed, Pro methods simply don't exist on the object
  • Core doesn't know about Pro: Adding Pro features = new capability in react-on-rails-pro, zero core changes
  • No timing dependency: One synchronous assembly call, explicit and ordered
  • No as unknown as type cast: Object built atomically, TypeScript verifies each capability
  • No baseObjectCreator indirection: Client vs Full = include ssrCapability or not
  • Tree-shakeable: Each capability is an independent module

Files to Change

react-on-rails package

  • src/types/index.ts — add decomposed tier interfaces alongside existing types
  • New src/capabilities/core.ts — extract from base/client.ts
  • New src/capabilities/lifecycle.ts — extract from clientStartup.ts + ClientRenderer.ts
  • New src/capabilities/ssr.ts — extract from base/full.ts
  • src/createReactOnRails.ts — rewrite as capability composer
  • src/ReactOnRails.client.ts — use capabilities
  • src/ReactOnRails.full.ts — use capabilities
  • package.json — add capabilities/* exports

react-on-rails-pro package

  • New src/capabilities/proFeatures.ts — extract from ComponentRegistry.ts + StoreRegistry.ts
  • New src/capabilities/proLifecycle.ts — extract from ClientSideRenderer.ts
  • New src/capabilities/streaming.ts — extract from streamServerRenderedReactComponent.ts
  • New src/capabilities/rsc.ts — extract from ReactOnRailsRSC.ts
  • src/createReactOnRailsPro.ts — remove, replaced by capabilities
  • src/ReactOnRails.full.ts — use capabilities
  • src/ReactOnRails.node.ts — use capabilities (no more raw property mutation)
  • src/ReactOnRailsRSC.ts — use capabilities

Backward Compatibility

  • ReactOnRails and ReactOnRailsInternal types remain identical (union of all tiers)
  • globalThis.ReactOnRails is still set with the same methods
  • All public methods have identical signatures
  • @internal exports kept as deprecated aliases during transition

Ref

See .claude/docs/architecture-rewrite/04-js-package-architecture.md for full design details.

Metadata

Metadata

Labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions