Skip to content

Commit

Permalink
Merge pull request #209 from tscircuit/async-effects
Browse files Browse the repository at this point in the history
Dirty Render Phase Implementation
  • Loading branch information
seveibar authored Oct 29, 2024
2 parents bf2c930 + a1b212a commit 04f8026
Show file tree
Hide file tree
Showing 3 changed files with 189 additions and 6 deletions.
40 changes: 40 additions & 0 deletions lib/Circuit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand Down Expand Up @@ -86,6 +88,25 @@ export class Circuit {
this._hasRenderedAtleastOnce = true
}

async renderUntilSettled(): Promise<void> {
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()
Expand Down Expand Up @@ -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)
}
}

/**
Expand Down
105 changes: 99 additions & 6 deletions lib/components/base-components/Renderable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,19 @@ export type RenderPhaseFn<K extends RenderPhase = RenderPhase> =
| `update${K}`
| `remove${K}`

export type RenderPhaseStates = Record<RenderPhase, { initialized: boolean }>
export type RenderPhaseStates = Record<
RenderPhase,
{
initialized: boolean
dirty: boolean
}
>

export type AsyncEffect = {
promise: Promise<void>
phase: RenderPhase
complete: boolean
}

export type RenderPhaseFunctions = {
[T in RenderPhaseFn]?: () => void
Expand All @@ -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<void>) {
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)
Expand All @@ -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 {
Expand Down
50 changes: 50 additions & 0 deletions tests/components/base-components/renderable-dirty-pattern.test.tsx
Original file line number Diff line number Diff line change
@@ -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)
})

0 comments on commit 04f8026

Please sign in to comment.