diff --git a/lib/Circuit.ts b/lib/Circuit.ts index db61dc0..5354adf 100644 --- a/lib/Circuit.ts +++ b/lib/Circuit.ts @@ -6,6 +6,8 @@ import { isValidElement, type ReactElement } from "react" import { createInstanceFromReactElement } from "./fiber/create-instance-from-react-element" import { identity, type Matrix } from "transformation-matrix" +type RootCircuitEventName = "asyncEffectComplete" + export class Circuit { firstChild: PrimitiveComponent | null = null children: PrimitiveComponent[] @@ -86,6 +88,25 @@ export class Circuit { this._hasRenderedAtleastOnce = true } + async renderUntilSettled(): Promise { + this.render() + + // TODO: use this.on("asyncEffectComplete", ...) instead + while (this._hasIncompleteAsyncEffects()) { + await new Promise((resolve) => setTimeout(resolve, 100)) // Small delay + this.render() + } + } + + private _hasIncompleteAsyncEffects(): boolean { + return this.children.some((child) => { + if (child._hasIncompleteAsyncEffects()) return true + return child.children.some((grandchild) => + grandchild._hasIncompleteAsyncEffects(), + ) + }) + } + getSoup(): AnyCircuitElement[] { if (!this._hasRenderedAtleastOnce) this.render() return this.db.toArray() @@ -142,6 +163,25 @@ export class Circuit { ): PrimitiveComponent | null { return this.firstChild?.selectOne(selector, opts) ?? null } + + _eventListeners: Record< + RootCircuitEventName, + Array<(...args: any[]) => void> + > = { asyncEffectComplete: [] } + + emit(event: RootCircuitEventName, ...args: any[]) { + if (!this._eventListeners[event]) return + for (const listener of this._eventListeners[event]) { + listener(...args) + } + } + + on(event: RootCircuitEventName, listener: (...args: any[]) => void) { + if (!this._eventListeners[event]) { + this._eventListeners[event] = [] + } + this._eventListeners[event]!.push(listener) + } } /** diff --git a/lib/components/base-components/Renderable.ts b/lib/components/base-components/Renderable.ts index 1a75961..81d6839 100644 --- a/lib/components/base-components/Renderable.ts +++ b/lib/components/base-components/Renderable.ts @@ -35,7 +35,19 @@ export type RenderPhaseFn = | `update${K}` | `remove${K}` -export type RenderPhaseStates = Record +export type RenderPhaseStates = Record< + RenderPhase, + { + initialized: boolean + dirty: boolean + } +> + +export type AsyncEffect = { + promise: Promise + phase: RenderPhase + complete: boolean +} export type RenderPhaseFunctions = { [T in RenderPhaseFn]?: () => void @@ -62,16 +74,72 @@ export abstract class Renderable implements IRenderable { isSchematicPrimitive = false _renderId: string + _currentRenderPhase: RenderPhase | null = null + + private _asyncEffects: AsyncEffect[] = [] constructor(props: any) { this._renderId = `${globalRenderCounter++}` this.children = [] this.renderPhaseStates = {} as RenderPhaseStates for (const phase of orderedRenderPhases) { - this.renderPhaseStates[phase] = { initialized: false } + this.renderPhaseStates[phase] = { + initialized: false, + dirty: false, + } + } + } + + protected _markDirty(phase: RenderPhase) { + this.renderPhaseStates[phase].dirty = true + // Mark all subsequent phases as dirty + const phaseIndex = orderedRenderPhases.indexOf(phase) + for (let i = phaseIndex + 1; i < orderedRenderPhases.length; i++) { + this.renderPhaseStates[orderedRenderPhases[i]].dirty = true } } + protected _queueAsyncEffect(effect: () => Promise) { + const asyncEffect: AsyncEffect = { + promise: effect(), // TODO don't start effects until end of render cycle + phase: this._currentRenderPhase!, + complete: false, + } + this._asyncEffects.push(asyncEffect) + + // Set up completion handler + asyncEffect.promise + .then(() => { + asyncEffect.complete = true + // HACK: emit to the root circuit component that an async effect has completed + if ("root" in this && this.root) { + ;(this.root as any).emit("asyncEffectComplete", { + component: this, + asyncEffect, + }) + } + }) + .catch((error) => { + console.error( + `Async effect error in ${this._currentRenderPhase}:`, + error, + ) + asyncEffect.complete = true + + // HACK: emit to the root circuit component that an async effect has completed + if ("root" in this && this.root) { + ;(this.root as any).emit("asyncEffectComplete", { + component: this, + asyncEffect, + }) + } + }) + } + + _hasIncompleteAsyncEffects(): boolean { + return this._asyncEffects.some((effect) => !effect.complete) + } + runRenderCycle() { for (const renderPhase of orderedRenderPhases) { this.runRenderPhaseForChildren(renderPhase) @@ -87,19 +155,44 @@ export abstract class Renderable implements IRenderable { * ...depending on the current state of the component. */ runRenderPhase(phase: RenderPhase) { - const isInitialized = this.renderPhaseStates[phase].initialized + this._currentRenderPhase = phase + const phaseState = this.renderPhaseStates[phase] + const isInitialized = phaseState.initialized + const isDirty = phaseState.dirty + + // Skip if component is being removed and not initialized if (!isInitialized && this.shouldBeRemoved) return + + // Handle removal if (this.shouldBeRemoved && isInitialized) { ;(this as any)?.[`remove${phase}`]?.() - this.renderPhaseStates[phase].initialized = false + phaseState.initialized = false + phaseState.dirty = false return } + + // Check for incomplete async effects from previous phases + const prevPhaseIndex = orderedRenderPhases.indexOf(phase) - 1 + if (prevPhaseIndex >= 0) { + const prevPhase = orderedRenderPhases[prevPhaseIndex] + const hasIncompleteEffects = this._asyncEffects + .filter((e) => e.phase === prevPhase) + .some((e) => !e.complete) + if (hasIncompleteEffects) return + } + + // Handle updates if (isInitialized) { - ;(this as any)?.[`update${phase}`]?.() + if (isDirty) { + ;(this as any)?.[`update${phase}`]?.() + phaseState.dirty = false + } return } + // Initial render + phaseState.dirty = false ;(this as any)?.[`doInitial${phase}`]?.() - this.renderPhaseStates[phase].initialized = true + phaseState.initialized = true } runRenderPhaseForChildren(phase: RenderPhase): void { diff --git a/tests/components/base-components/renderable-dirty-pattern.test.tsx b/tests/components/base-components/renderable-dirty-pattern.test.tsx new file mode 100644 index 0000000..19a220f --- /dev/null +++ b/tests/components/base-components/renderable-dirty-pattern.test.tsx @@ -0,0 +1,50 @@ +import { test, expect } from "bun:test" +import { Renderable } from "lib/components/base-components/Renderable" + +class TestComponent extends Renderable { + constructor() { + super({}) + } + + doInitialSourceRender() { + // Test async effect + this._queueAsyncEffect(async () => { + await new Promise((resolve) => setTimeout(resolve, 100)) + }) + } + + doInitialSchematicComponentRender() { + // Test marking dirty + this._markDirty("SchematicComponentRender") + } +} + +test("dirty pattern and async effects", async () => { + const component = new TestComponent() + + // Initial state + expect(component.renderPhaseStates.SourceRender.dirty).toBe(false) + expect(component.renderPhaseStates.SchematicComponentRender.dirty).toBe(false) + + // Run initial render phases + component.runRenderPhase("SourceRender") + + // Check async effects + expect(component._hasIncompleteAsyncEffects()).toBe(true) + + // Wait for async effects + await new Promise((resolve) => setTimeout(resolve, 150)) + expect(component._hasIncompleteAsyncEffects()).toBe(false) + + // Test marking phases dirty + component.runRenderPhase("SchematicComponentRender") + + // SchematicComponentRender and subsequent phases should be marked dirty + expect(component.renderPhaseStates.SchematicComponentRender.dirty).toBe(true) + expect(component.renderPhaseStates.SchematicLayout.dirty).toBe(true) + expect(component.renderPhaseStates.SchematicTraceRender.dirty).toBe(true) + + // Previous phases should not be dirty + expect(component.renderPhaseStates.SourceRender.dirty).toBe(false) + expect(component.renderPhaseStates.PortDiscovery.dirty).toBe(false) +})