Skip to content

Architecture: Decouple global state from fx singleton #11

@DeanLJY

Description

@DeanLJY

The fx singleton exposes 116+ static members and relies heavily on global state (globalThis.fx). This creates several architectural issues:

  1. Testing difficulty: Cannot isolate units due to shared global state
  2. Multiple instances impossible: Cannot run parallel formula contexts
  3. Hidden dependencies: Components rely on global state implicitly
  4. Memory leaks: Global state persists even when not needed
  5. SSR incompatibility: Server-side rendering requires isolated contexts

Current Architecture

// Global singleton pattern
class System {
  static player: Player | null
  static sceneFolder: Folder | null
  static targetFolder: Folder | null
  static cache: Map<string, unknown>
  // ... 112 more static members
}

globalThis.fx = System

Problems:

// Test A modifies global state
fx.setPlayer(playerA)

// Test B sees Test A's state (pollution)
const player = fx.getPlayer()  // Returns playerA!

// Cannot create isolated contexts
const context1 = ???  // Not possible
const context2 = ???  // Not possible

Proposed Solution: Context-Based Architecture

1. FxContext Class

// src/core/FxContext.ts
export interface FxContextOptions {
  id?: string
  parent?: FxContext
  isolated?: boolean
}

export class FxContext {
  readonly id: string
  private parent?: FxContext

  // Instance state (not static)
  private player: Player | null = null
  private sceneFolder: Folder | null = null
  private targetFolder: Folder | null = null
  private cache: Map<string, unknown> = new Map()
  private eventManager: EventManager

  constructor(options: FxContextOptions = {}) {
    this.id = options.id ?? generateId()
    this.parent = options.parent
    this.eventManager = new EventManager()
  }

  // Player management
  setPlayer(player: Player): void { this.player = player }
  getPlayer(): Player | null { return this.player ?? this.parent?.getPlayer() ?? null }

  // Folder management
  setSceneFolder(folder: Folder): void { this.sceneFolder = folder }
  getSceneFolder(): Folder | null { return this.sceneFolder }

  // Formula evaluation
  evaluate(expression: string): number {
    return this.expressionParser.evaluate(expression, this.getScope())
  }

  // Create child context (inherits from parent)
  createChild(options?: Partial<FxContextOptions>): FxContext {
    return new FxContext({ ...options, parent: this })
  }

  // Cleanup
  dispose(): void {
    this.cache.clear()
    this.eventManager.removeAllListeners()
    this.player = null
    this.sceneFolder = null
  }
}

2. Context Factory

// src/core/FxContextFactory.ts
export class FxContextFactory {
  private static contexts: Map<string, FxContext> = new Map()
  private static defaultContext: FxContext | null = null

  static create(options?: FxContextOptions): FxContext {
    const context = new FxContext(options)
    this.contexts.set(context.id, context)
    return context
  }

  static getDefault(): FxContext {
    if (!this.defaultContext) {
      this.defaultContext = this.create({ id: 'default' })
    }
    return this.defaultContext
  }

  static get(id: string): FxContext | undefined {
    return this.contexts.get(id)
  }

  static dispose(id: string): void {
    const context = this.contexts.get(id)
    context?.dispose()
    this.contexts.delete(id)
  }

  static disposeAll(): void {
    this.contexts.forEach(ctx => ctx.dispose())
    this.contexts.clear()
    this.defaultContext = null
  }
}

3. Backward-Compatible Facade

// src/index.ts
// Maintain backward compatibility
export const fx = {
  // Delegate to default context
  get player() { return FxContextFactory.getDefault().getPlayer() },
  setPlayer(p: Player) { FxContextFactory.getDefault().setPlayer(p) },

  evaluate(expr: string) {
    return FxContextFactory.getDefault().evaluate(expr)
  },

  // New: Context management
  createContext(options?: FxContextOptions): FxContext {
    return FxContextFactory.create(options)
  },

  getContext(id: string): FxContext | undefined {
    return FxContextFactory.get(id)
  },

  // ... other delegated methods
}

4. Usage Examples

Isolated Testing

describe('Player Tests', () => {
  let context: FxContext

  beforeEach(() => {
    context = fx.createContext({ isolated: true })
  })

  afterEach(() => {
    context.dispose()
  })

  it('should calculate damage', () => {
    context.setPlayer(new Player({ ATK: 100 }))
    const damage = context.evaluate('ATK * 2')
    expect(damage).toBe(200)
  })
})

Multiple Game Instances

// Multiplayer server with isolated contexts per session
class GameSession {
  private context: FxContext

  constructor(sessionId: string) {
    this.context = fx.createContext({ id: sessionId })
  }

  processAction(action: Action): void {
    // Each session has isolated state
    this.context.evaluate(action.formula)
  }

  destroy(): void {
    this.context.dispose()
  }
}

Nested Contexts (Inheritance)

// Global defaults
const globalContext = fx.createContext({ id: 'global' })
globalContext.setVariable('BASE_DAMAGE', 10)

// Combat context inherits global
const combatContext = globalContext.createChild({ id: 'combat' })
combatContext.setVariable('CRIT_MULTIPLIER', 1.5)

// Can access both
combatContext.evaluate('BASE_DAMAGE * CRIT_MULTIPLIER')  // Works!

Migration Path

Phase 1: Internal Refactoring

  • Convert static members to instance members
  • Create FxContext class internally
  • No public API changes

Phase 2: Expose Context API

  • Add fx.createContext() method
  • Document new context-based approach
  • Maintain backward compatibility

Phase 3: Deprecate Global Access

  • Log deprecation warnings for direct global access
  • Recommend context-based usage
  • Update documentation

Phase 4: Remove Global State (Major Version)

  • Remove globalThis.fx exposure
  • Require explicit context creation
  • Breaking change in v3.0.0

Benefits

Aspect Before After
Testing Shared state pollution Isolated contexts
Parallelism Single instance Multiple instances
Memory Persistent global Disposable contexts
SSR Not compatible Fully compatible
Dependencies Hidden globals Explicit injection

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions