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:
createBaseClientObject / createBaseFullObject → base methods + stub methods that throw
createReactOnRails → overwrites lifecycle stubs, adds Pro stubs that throw "requires Pro"
createReactOnRailsPro → overwrites Pro stubs with real implementations, has timing-dependent checks
ReactOnRails.node.ts → raw property mutation: ReactOnRails.streamServerRenderedReactComponent = ...
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.
Summary
Replace the current 5-stage
Object.assignmutation pattern for building theReactOnRailsglobal object with a single-step capability-based composition pattern.Problem
The
ReactOnRailsJS global object is currently built through 5 mutation stages:createBaseClientObject/createBaseFullObject→ base methods + stub methods that throwcreateReactOnRails→ overwrites lifecycle stubs, adds Pro stubs that throw "requires Pro"createReactOnRailsPro→ overwrites Pro stubs with real implementations, has timing-dependent checksReactOnRails.node.ts→ raw property mutation:ReactOnRails.streamServerRenderedReactComponent = ...ReactOnRailsRSC.ts→ raw property mutation:ReactOnRails.serverRenderRSCReactComponent = ...This creates several issues:
createReactOnRails.tsdefines stubs forgetOrWaitForComponent,streamServerRenderedReactComponent, etc. Adding a new Pro feature requires modifying the core package.createReactOnRailsPro.ts:146-154must check "did Stage 4 already addstreamServerRenderedReactComponent?" before overwriting.as unknown as ReactOnRailsInternalbecause TypeScript can't track mutations viaObject.assign.baseObjectCreatorparameter adds indirection — the difference between "client" and "full" bundles could just be whether the SSR capability is included.Proposed Solution
1. Decompose
ReactOnRailsInternalinto tier interfaces2. Define capability objects typed against their tier
TypeScript validates each capability at definition time.
3. Single-step composition via
createReactOnRails4. Entry points declare their exact type
Benefits
react-on-rails-pro, zero core changesas unknown astype cast: Object built atomically, TypeScript verifies each capabilitybaseObjectCreatorindirection: Client vs Full = includessrCapabilityor notFiles to Change
react-on-rails package
src/types/index.ts— add decomposed tier interfaces alongside existing typessrc/capabilities/core.ts— extract frombase/client.tssrc/capabilities/lifecycle.ts— extract fromclientStartup.ts+ClientRenderer.tssrc/capabilities/ssr.ts— extract frombase/full.tssrc/createReactOnRails.ts— rewrite as capability composersrc/ReactOnRails.client.ts— use capabilitiessrc/ReactOnRails.full.ts— use capabilitiespackage.json— addcapabilities/*exportsreact-on-rails-pro package
src/capabilities/proFeatures.ts— extract fromComponentRegistry.ts+StoreRegistry.tssrc/capabilities/proLifecycle.ts— extract fromClientSideRenderer.tssrc/capabilities/streaming.ts— extract fromstreamServerRenderedReactComponent.tssrc/capabilities/rsc.ts— extract fromReactOnRailsRSC.tssrc/createReactOnRailsPro.ts— remove, replaced by capabilitiessrc/ReactOnRails.full.ts— use capabilitiessrc/ReactOnRails.node.ts— use capabilities (no more raw property mutation)src/ReactOnRailsRSC.ts— use capabilitiesBackward Compatibility
ReactOnRailsandReactOnRailsInternaltypes remain identical (union of all tiers)globalThis.ReactOnRailsis still set with the same methods@internalexports kept as deprecated aliases during transitionRef
See
.claude/docs/architecture-rewrite/04-js-package-architecture.mdfor full design details.