From eccd58256e9b2a0c0365f051569d27534b072bf8 Mon Sep 17 00:00:00 2001 From: seveibar Date: Mon, 28 Oct 2024 17:59:34 -0700 Subject: [PATCH 1/4] dirty render phase implementation --- lib/Circuit.ts | 16 ++++ lib/components/base-components/Renderable.ts | 87 +++++++++++++++++-- .../renderable-dirty-pattern.test.tsx | 50 +++++++++++ 3 files changed, 147 insertions(+), 6 deletions(-) create mode 100644 tests/components/base-components/renderable-dirty-pattern.test.tsx diff --git a/lib/Circuit.ts b/lib/Circuit.ts index db61dc0..ed546ce 100644 --- a/lib/Circuit.ts +++ b/lib/Circuit.ts @@ -86,6 +86,22 @@ export class Circuit { this._hasRenderedAtleastOnce = true } + async renderUntilSettled(): Promise { + this.render() + + 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() diff --git a/lib/components/base-components/Renderable.ts b/lib/components/base-components/Renderable.ts index 1a75961..ba1a7bd 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,14 +74,52 @@ 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 + }) + .catch((error) => { + console.error(`Async effect error in ${phase}:`, error) + asyncEffect.complete = true + }) + } + + hasIncompleteAsyncEffects(): boolean { + return this._asyncEffects.some((effect) => !effect.complete) } runRenderCycle() { @@ -87,19 +137,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..db02dbb --- /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) +}) From 9b97202b5c1a0bd533dd2fc7ad743bf619623e99 Mon Sep 17 00:00:00 2001 From: seveibar Date: Mon, 28 Oct 2024 18:01:51 -0700 Subject: [PATCH 2/4] minor rename --- lib/components/base-components/Renderable.ts | 2 +- .../base-components/renderable-dirty-pattern.test.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/components/base-components/Renderable.ts b/lib/components/base-components/Renderable.ts index ba1a7bd..db8eec5 100644 --- a/lib/components/base-components/Renderable.ts +++ b/lib/components/base-components/Renderable.ts @@ -118,7 +118,7 @@ export abstract class Renderable implements IRenderable { }) } - hasIncompleteAsyncEffects(): boolean { + _hasIncompleteAsyncEffects(): boolean { return this._asyncEffects.some((effect) => !effect.complete) } diff --git a/tests/components/base-components/renderable-dirty-pattern.test.tsx b/tests/components/base-components/renderable-dirty-pattern.test.tsx index db02dbb..19a220f 100644 --- a/tests/components/base-components/renderable-dirty-pattern.test.tsx +++ b/tests/components/base-components/renderable-dirty-pattern.test.tsx @@ -30,11 +30,11 @@ test("dirty pattern and async effects", async () => { component.runRenderPhase("SourceRender") // Check async effects - expect(component.hasIncompleteAsyncEffects()).toBe(true) + expect(component._hasIncompleteAsyncEffects()).toBe(true) // Wait for async effects await new Promise((resolve) => setTimeout(resolve, 150)) - expect(component.hasIncompleteAsyncEffects()).toBe(false) + expect(component._hasIncompleteAsyncEffects()).toBe(false) // Test marking phases dirty component.runRenderPhase("SchematicComponentRender") From fe5ed4e7a62940bc0dd7cd3a663e4a1ac88aa6d6 Mon Sep 17 00:00:00 2001 From: seveibar Date: Mon, 28 Oct 2024 19:44:14 -0700 Subject: [PATCH 3/4] emit events when async rendering completes --- lib/Circuit.ts | 37 ++++++++++++++++---- lib/components/base-components/Renderable.ts | 20 ++++++++++- 2 files changed, 49 insertions(+), 8 deletions(-) diff --git a/lib/Circuit.ts b/lib/Circuit.ts index ed546ce..c342b0e 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[] @@ -88,17 +90,19 @@ export class Circuit { async renderUntilSettled(): Promise { this.render() - - while (this.hasIncompleteAsyncEffects()) { - await new Promise(resolve => setTimeout(resolve, 100)) // Small delay + + 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()) + private _hasIncompleteAsyncEffects(): boolean { + return this.children.some((child) => { + if (child._hasIncompleteAsyncEffects()) return true + return child.children.some((grandchild) => + grandchild._hasIncompleteAsyncEffects(), + ) }) } @@ -158,6 +162,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 db8eec5..81d6839 100644 --- a/lib/components/base-components/Renderable.ts +++ b/lib/components/base-components/Renderable.ts @@ -111,10 +111,28 @@ export abstract class Renderable implements IRenderable { 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 ${phase}:`, 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, + }) + } }) } From a1b212a0e14503d413991406761a6444ab4eb478 Mon Sep 17 00:00:00 2001 From: seveibar Date: Mon, 28 Oct 2024 19:45:16 -0700 Subject: [PATCH 4/4] typefixes and TODO for event emitter usage --- lib/Circuit.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/Circuit.ts b/lib/Circuit.ts index c342b0e..5354adf 100644 --- a/lib/Circuit.ts +++ b/lib/Circuit.ts @@ -91,6 +91,7 @@ export class Circuit { 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()