Giter VIP home page Giter VIP logo

redwood's Introduction

Redwood

Redwood is a library for building reactive Android, iOS, and web UIs using Kotlin.

Reactive UIs

Android and iOS UI frameworks model the user interface as a ‘mutable view tree’ or document object model (DOM). To build an application using the mutable view tree abstraction, the programmer performs two discrete steps:

  • Build the static view tree. In Android the conventional tool for this is layout XML, though we've done some cool work with Contour to build view trees with Kotlin lambdas.

  • Make it dance. The view tree should change in response to user actions (like pushing buttons) and external events (like data loading). The program mutates the view tree to represent the current application state. Some mutations change the on-screen UI instantly; others animate smoothly from the old state to the new state.

React popularized a new programming model, reactive UIs. With reactive UIs, the programmer writes a render() function that accepts the application state and returns a view tree. The framework calls this function with the initial application state and again each time the application state changes. The framework analyzes the differences between pairs of view trees and updates the display, including animating transitions where appropriate.

In React the view tree returned by the render function is called a virtual DOM, and it has an on-screen counterpart called the real DOM. The virtual DOM is a tree of simple JavaScript value objects; the real DOM is a tree of live browser HTML components. Creating and traversing thousands of virtual DOM objects is fast; creating thousands of HTML components is not! Therefore, the virtual DOM optimization is the magic that makes React work.

Compose

Jetpack Compose is an implementation of the reactive UI model for Android. It uses an implementation trick to further optimize the reactive programming model. It is implemented in two complementary modules:

  • The Compose compiler is a Kotlin compiler plugin that supports partial re-evaluation of a function. The programmer still writes render functions to transform application state into a view tree. The compiler rewrites this function to track which inputs yield which outputs. When the input application state changes, it evaluates only what is necessary to generate the corresponding view tree changes.

  • Compose UI is a new set of Android UI components designed to work with the Compose compiler. It addresses longstanding technical debt with Android's view system.

A Kotlin function that is rewritten by the Compose compiler is called a composable function. Partial re-evaluation of a composable function is called recomposing.

Note that the Compose compiler can be used without Compose UI. For example, compose-server-side renders HTML components on a server that are sent to a browser over a WebSocket.

Design Systems

In Cash App we use a design system. It specifies our UI in detail and names its elements:

  • Names for our standard colors, fonts, icons, dimensions
  • Named text blocks, specified using the names above
  • Named controls, such as our standard checkboxes, buttons, and dialogs

The design system helps with collaboration between programmers and designers. It also increases uniformity within the application and across platforms.

What Is Redwood?

Redwood integrates the Compose compiler, a design system, and a set of platform-specific displays. Each Redwood project is implemented in three parts:

  • A design system. Redwood includes a sample design system called ‘Sunspot’. Most applications should customize this to match their product needs.

  • Displays for UI platforms. The display draws the pixels of the design system on-screen. Displays can be implemented for any UI platform. Redwood includes sample displays for Sunspot for Android, iOS, and web.

  • Composable Functions. This is client logic that accepts application state and returns elements of the design system. These have similar responsibilities to presenters in an MVP system.

Why Redwood?

We're eager to start writing reactive UIs! But we're reluctant to continue duplicating code across iOS, Android, and web platforms. In particular, we don't like how supporting multiple platforms reduces our overall agility.

We'd like to shortcut the slow native UI development process. Iterating on UIs for Android requires a slow compile step and a slow adb install step. With Redwood, we hope to use the web as our development target while we iterate on composable function changes.

We want the option to change application behavior without waiting for users to update their apps. With Kotlin/JS we may be able to update our composable functions at application launch time, and run them in a JavaScript VM. We may even be able to use WebAssembly to accomplish this with little performance penalty.

Redwood is a library, not a framework. It is designed to be adopted incrementally, and to be low-risk to integrate in an existing Android project. Using Redwood in an iOS or web application is riskier! We've had good experiences with Kotlin Multiplatform Mobile, and expect a similar outcome with Redwood.

Code Sample

We start by expressing our design system as a set of Kotlin data classes. Redwood will use these classes to generate type-safe APIs for the displays and composable functions.

@Widget(1)
data class Text(
  @Property(1) val text: String?,
  @Property(2) @Default("\"black\"") val color: String,
)

@Widget(2)
data class Button(
  @Property(1) val text: String?,
  @Property(2) @Default("true") val enabled: Boolean,
  @Property(3) val onClick: () -> Unit,
)

Displays implement the design system using native UI components.

class AndroidText(
  override val value: TextView,
) : Text<View> {
  override fun text(text: String?) {
    value.text = text
  }

  override fun color(color: String) {
    value.setTextColor(Color.parseColor(color))
  }
}

Composable functions render application state into the design system. These will make use of Compose API features like remember().

@Composable
fun Counter(value: Int = 0) {
  var count by remember { mutableStateOf(value) }

  Button("-1", onClick = { count-- })
  Text(count.toString())
  Button("+1", onClick = { count++ })
}

redwood's People

Contributors

ahmed3elshaer avatar aleckazakova avatar apatronl avatar chrisbanes avatar colinrtwhite avatar dellisd avatar dependabot[bot] avatar dgmltn avatar dnagler avatar drewhamilton avatar dylansale avatar efirestone avatar eygraber avatar fryn avatar goooler avatar huanglizhuo avatar hunterestrada avatar ignatberesnev avatar jakewharton avatar kpgalligan avatar moetouban avatar renovate[bot] avatar saket avatar swankjesse avatar tadeaskriz avatar tso avatar underscoretang avatar veyndan avatar yissachar avatar zacsweers avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

redwood's Issues

Parse schema using FIR instead of reflection

Switch to using FIR so that we can:

  • Parse default expressions on properties

    @Widget(1)
    data class Button(
      @Property(1) val bgColor: String = "red",
    )

    will change the generated code:

     @Composable
     fun Button(
    -  bgColor: String,
    +  bgColor: String = "red",
     ) { .. }
  • Retain comments on the type and property.

    /** Hey I'm a button */
    @Widget(1)
    data class Button(
      /** Background color, will be darkened 10% for touch state. */
      val bgColor: String,
    )

    will change the generated code:

    +/** Hey I'm a button */
     interface Button {
    +  /** Background color, will be darkened 10% for touch state. */
       fun bgColor(bgColor: String)
     }

    and

    +/** Hey I'm a button */
     @Composable
     fun Button(
    +  /** Background color, will be darkened 10% for touch state. */
       bgColor: String,
     ) { ... }
  • Capture parameter names on event lambdas.

    @Widget(1)
    data class Thing(
      @Property(1) val someEvent: (x: Int, y: Int) -> Unit,
    )

    will change the generated code:

     @Composable
     fun Thing(
    -  someEvent: (Int, Int) -> Unit,
    +  someEvent: (x: Int, y: Int) -> Unit,
     ) { .. }

Automatically apply dependency substitution for Android Compose runtime?

Following from #120 and the seemingly-impossible #121, since we have a Gradle plugin we can simply apply dependency substitution such that our Android variant is always replaced with the Jetpack one. While it would be better if #121 could be made to work, this is at least a stop-gap that prevents the consumer from having to muck around with dependencies in order to use the tool with Compose UI (or some other Compose-based library).

We should not have to track this and send it as part of the protocol.

We should not have to track this and send it as part of the protocol.
Ideally this would be entirely encapsulated on the display-side with additional bookkeeping.
For now, we track it here and send it in the protocol as a simple solution.

https://github.com/square/treehouse/blob/49a2cd422a708d5b66f60de3c53864c73a06907b/treehouse/treehouse-compose/src/commonMain/kotlin/app/cash/treehouse/compose/applier.kt#L185


  override fun remove(index: Int, count: Int) {
    // Children instances are never removed from their parents.
    val current = current as ProtocolChildrenNode
    val children = current.children

    // TODO We should not have to track this and send it as part of the protocol.
    //  Ideally this would be entirely encapsulated on the display-side with additional bookkeeping.
    //  For now, we track it here and send it in the protocol as a simple solution.
    val removedIds = ArrayList<Long>(count)
    for (i in index until index + count) {
      removedIds.add(children[i].id)
    }

    nodes.keys.removeAll(removedIds)
    children.remove(index, count)
    childrenDiffs.add(ChildrenDiff.Remove(current.id, current.tag, index, count, removedIds))
  }

  override fun move(from: Int, to: Int, count: Int) {

f4581e287383869f820a2f361bae67a3dcaacec5

we need to remove widgets from our map!

we need to remove widgets from our map!

https://github.com/square/treehouse/blob/1ad7b7242bb7c68e31465a3e65ecfd7bded817ae/treehouse-widget/src/commonMain/kotlin/app/cash/treehouse/widget/WidgetDisplay.kt#L30


package app.cash.treehouse.widget

import app.cash.treehouse.protocol.Diff
import app.cash.treehouse.protocol.WidgetDiff

class WidgetDisplay<T : Any>(
  private val root: Widget<T>,
  private val factory: Widget.Factory<T>,
  private val events: EventSink,
) {
  private val widgets = mutableMapOf(Diff.RootId to root)

  fun apply(diff: Diff) {
    for (widgetDiff in diff.widgetDiffs) {
      val widget = checkNotNull(widgets[widgetDiff.id]) {
        "Unknown widget ID ${widgetDiff.id}"
      }

      when (widgetDiff) {
        is WidgetDiff.Insert -> {
          val childWidget = factory.create(widget.value, widgetDiff.kind, widgetDiff.childId, events)
          widgets[widgetDiff.childId] = childWidget
          widget.children(widgetDiff.childrenIndex).insert(widgetDiff.index, childWidget.value)
        }
        is WidgetDiff.Move -> {
          widget.children(widgetDiff.childrenIndex).move(widgetDiff.fromIndex, widgetDiff.toIndex, widgetDiff.count)
        }
        is WidgetDiff.Remove -> {
          widget.children(widgetDiff.childrenIndex).remove(widgetDiff.index, widgetDiff.count)
          // TODO we need to remove widgets from our map!
        }
        WidgetDiff.Clear -> {
          widget.children(Diff.RootChildrenIndex).clear()
          widgets.clear()
          widgets[Diff.RootId] = root
        }
      }
    }

    for (propertyDiff in diff.propertyDiffs) {
      val widget = checkNotNull(widgets[propertyDiff.id]) {
        "Unknown widget iD ${propertyDiff.id}"
      }

      widget.apply(propertyDiff)
    }
  }
}

edf3f9a0d1576c577b8785b13ce44e6b2bcae7c4

Stateless `Widgets`

Currently, defining a widget requires that you use a data class. Data classes require at least one primary constructor property. This makes it impossible to define widgets without state.

It would be nice to define a stateless widget like so

@Widget(1) object ProgressIndicator

My motivating use case relates to an indeterminate progress indicator which will never have state.

If you alter the lambda for an event do not send a property diff

If you compose code is like

val timer by remember { mutableStateOf(true) }
Button("Click me!" onClick = if (timer) { foo() } else { bar() })
LaunchedEffect {
  delay(1_000)
  timer = false
}

We will send something like this:

ChildrenDiff.Insert(id = 0, tag = 1, type = 1, childId = 1)
PropertyDiff(id = 1, tag = 1, value = "Click me!")
PropertyDiff(id = 1, tag = 2, value = true)
// 1 second elapses
PropertyDiff(id = 1, tag = 2, value = true)

Similarly, if the lambda is null and it gets re-set to null (does this happen?) then we will send false twice. The node should remember its old has-lambda/no-lambda state and only send when it changes.

Native release linking fails due to LLVM validation

e: java.lang.Error: writing to immutable: $dirty
	at org.jetbrains.kotlin.backend.konan.llvm.VariableManager$ValueRecord.store(VariableManager.kt:43)
	at org.jetbrains.kotlin.backend.konan.llvm.VariableManager$ValueRecord.store(VariableManager.kt:41)
	at org.jetbrains.kotlin.backend.konan.llvm.VariableManager.store(VariableManager.kt:137)
	at org.jetbrains.kotlin.backend.konan.llvm.CodeGeneratorVisitor.evaluateSetValue(IrToBitcode.kt:1304)
	at org.jetbrains.kotlin.backend.konan.llvm.CodeGeneratorVisitor.evaluateExpression(IrToBitcode.kt:891)
	at org.jetbrains.kotlin.backend.konan.llvm.CodeGeneratorVisitor.generateWhenCase(IrToBitcode.kt:1222)
	at org.jetbrains.kotlin.backend.konan.llvm.CodeGeneratorVisitor.evaluateWhen(IrToBitcode.kt:1200)
	at org.jetbrains.kotlin.backend.konan.llvm.CodeGeneratorVisitor.evaluateExpression(IrToBitcode.kt:896)
	at org.jetbrains.kotlin.backend.konan.llvm.CodeGeneratorVisitor.generateStatement(IrToBitcode.kt:920)
	at org.jetbrains.kotlin.backend.konan.llvm.CodeGeneratorVisitor.visitFunction(IrToBitcode.kt:759)
	at org.jetbrains.kotlin.ir.visitors.IrElementVisitorVoid$DefaultImpls.visitSimpleFunction(IrElementVisitorVoid.kt:52)
	at org.jetbrains.kotlin.backend.konan.llvm.CodeGeneratorVisitor.visitSimpleFunction(IrToBitcode.kt:188)
	at org.jetbrains.kotlin.ir.visitors.IrElementVisitorVoid$DefaultImpls.visitSimpleFunction(IrElementVisitorVoid.kt:53)
	at org.jetbrains.kotlin.backend.konan.llvm.CodeGeneratorVisitor.visitSimpleFunction(IrToBitcode.kt:188)
	at org.jetbrains.kotlin.backend.konan.llvm.CodeGeneratorVisitor.visitSimpleFunction(IrToBitcode.kt:188)
	at org.jetbrains.kotlin.ir.declarations.IrSimpleFunction.accept(IrSimpleFunction.kt:29)
	at org.jetbrains.kotlin.ir.declarations.impl.IrFileImpl.acceptChildren(IrFileImpl.kt:66)
	at org.jetbrains.kotlin.ir.visitors.IrElementVisitorVoidKt.acceptChildrenVoid(IrElementVisitorVoid.kt:275)
	at org.jetbrains.kotlin.backend.konan.llvm.CodeGeneratorVisitor$visitFile$1$1.invoke(IrToBitcode.kt:528)
	at org.jetbrains.kotlin.backend.konan.llvm.CodeGeneratorVisitor$visitFile$1$1.invoke(IrToBitcode.kt:527)
	at org.jetbrains.kotlin.backend.konan.llvm.CodeGeneratorVisitor.runAndProcessInitializers(IrToBitcode.kt:331)
	at org.jetbrains.kotlin.backend.konan.llvm.CodeGeneratorVisitor.visitFile(IrToBitcode.kt:527)
	at org.jetbrains.kotlin.ir.visitors.IrElementVisitorVoid$DefaultImpls.visitFile(IrElementVisitorVoid.kt:38)
	at org.jetbrains.kotlin.backend.konan.llvm.CodeGeneratorVisitor.visitFile(IrToBitcode.kt:188)
	at org.jetbrains.kotlin.backend.konan.llvm.CodeGeneratorVisitor.visitFile(IrToBitcode.kt:188)
	at org.jetbrains.kotlin.ir.declarations.impl.IrFileImpl.accept(IrFileImpl.kt:63)
	at org.jetbrains.kotlin.backend.konan.serialization.KonanIrModuleFragmentImpl.acceptChildren(KonanIrlinker.kt:253)
	at org.jetbrains.kotlin.ir.visitors.IrElementVisitorVoidKt.acceptChildrenVoid(IrElementVisitorVoid.kt:275)
	at org.jetbrains.kotlin.backend.konan.llvm.CodeGeneratorVisitor.visitModuleFragment(IrToBitcode.kt:355)
	at org.jetbrains.kotlin.ir.visitors.IrElementVisitorVoid$DefaultImpls.visitModuleFragment(IrElementVisitorVoid.kt:28)
	at org.jetbrains.kotlin.backend.konan.llvm.CodeGeneratorVisitor.visitModuleFragment(IrToBitcode.kt:188)
	at org.jetbrains.kotlin.backend.konan.llvm.CodeGeneratorVisitor.visitModuleFragment(IrToBitcode.kt:188)
	at org.jetbrains.kotlin.backend.konan.serialization.KonanIrModuleFragmentImpl.accept(KonanIrlinker.kt:250)
	at org.jetbrains.kotlin.ir.visitors.IrElementVisitorVoidKt.acceptVoid(IrElementVisitorVoid.kt:271)
	at org.jetbrains.kotlin.backend.konan.llvm.BitcodePhasesKt$codegenPhase$1.invoke(BitcodePhases.kt:269)
	at org.jetbrains.kotlin.backend.konan.llvm.BitcodePhasesKt$codegenPhase$1.invoke(BitcodePhases.kt:268)
	at org.jetbrains.kotlin.backend.konan.KonanLoweringPhasesKt$makeKonanModuleOpPhase$1.invoke(KonanLoweringPhases.kt:63)
	at org.jetbrains.kotlin.backend.konan.KonanLoweringPhasesKt$makeKonanModuleOpPhase$1.invoke(KonanLoweringPhases.kt:61)
	at org.jetbrains.kotlin.backend.common.phaser.NamedCompilerPhase.invoke(CompilerPhase.kt:94)
	at org.jetbrains.kotlin.backend.common.phaser.CompositePhase.invoke(PhaseBuilders.kt:30)
	at org.jetbrains.kotlin.backend.common.phaser.NamedCompilerPhase.invoke(CompilerPhase.kt:94)
	at org.jetbrains.kotlin.backend.common.phaser.CompositePhase.invoke(PhaseBuilders.kt:30)
	at org.jetbrains.kotlin.backend.common.phaser.NamedCompilerPhase.invoke(CompilerPhase.kt:94)
	at org.jetbrains.kotlin.backend.common.phaser.CompositePhase.invoke(PhaseBuilders.kt:23)
	at org.jetbrains.kotlin.backend.common.phaser.NamedCompilerPhase.invoke(CompilerPhase.kt:94)
	at org.jetbrains.kotlin.backend.common.phaser.CompositePhase.invoke(PhaseBuilders.kt:30)
	at org.jetbrains.kotlin.backend.common.phaser.NamedCompilerPhase.invoke(CompilerPhase.kt:94)
	at org.jetbrains.kotlin.backend.common.phaser.CompilerPhaseKt.invokeToplevel(CompilerPhase.kt:41)
	at org.jetbrains.kotlin.backend.konan.KonanDriverKt.runTopLevelPhases(KonanDriver.kt:29)

From JetBrains / Google:

Yeah, specifically, we abuse the val semantics there. dirty is a mutable variable, but we don't want to mark it as var since that will cause a Ref<Int> to get created if it is captured. Since we know we will never be mutating this variable after it gets captured .We have a val that we write to in such a way that the val is conceptually immutable but we actually perform a mutation. We use val instead of var because we want to avoid creating wrapper Refs

Workaround in https://github.com/JetBrains/androidx/blob/739b9c50542e0e1c62fcc3390d04f3d72034dd4f/compose/compiler/compiler-hosted/src/main/java/androidx/compose/compiler/plugins/kotlin/lower/ComposableFunctionBodyTransformer.kt#L906

JetBrains to upstream workaround into AOSP soon.

Polymorphic applier/scope to avoid protocol indirection

Right now if you're running a composition we go through our ProtocolApplier which creates protocol models (for optional serialization) which are then parsed by the display layer and applied to the display nodes. If you are targeting the same Kotlin backend with both the Compose layer and the display layer then this indirection does nothing but create garbage.

The working idea for fixing this is to make the Compose code generate depend on the Display generation. Then the protocol indirection can be created by generating implementations of the display nodes which create protocol models.

When setting up the TreehouseComposition, we would then have the ability to supply either the protocol-based display system or the real display system.

This also would be the mechanism by which #1 is accomplished. You supply an applier/scope/whatever that creates instances of your schema models for testing purposes.

Parse color. Actual TODO: Use semantic color type rather than hex string.

Parse color. Actual TODO: Use semantic color type rather than hex string.

https://github.com/square/treehouse/blob/49501af708d619646a3fe0d7d18fcfd5148abf87/samples/counter/ios/shared/src/iosMain/kotlin/example/ios/sunspot/IosSunspotText.kt#L35


/*
 * Copyright (C) 2021 Square, Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package example.ios.sunspot

import example.sunspot.widget.SunspotText
import platform.UIKit.NSTextAlignmentCenter
import platform.UIKit.UIColor
import platform.UIKit.UILabel
import platform.UIKit.UIView

class IosSunspotText : SunspotText<UIView> {
  override val value = UILabel().apply {
    textColor = UIColor.whiteColor // TODO why is this needed?
    textAlignment = NSTextAlignmentCenter
  }

  override fun text(text: String?) {
    value.text = text
  }

  override fun color(color: String) {
    // TODO Parse color. Actual TODO: Use semantic color type rather than hex string.
  }
}

c3b416c61e6a2cd1c428ffd6f6c679c842a08e5a

We cannot actually guarantee this is the tag that the display root uses for chil...

We cannot actually guarantee this is the tag that the display root uses for children.

https://github.com/square/treehouse/blob/6b314e67a9609e0ea7f9c67926f6d832627cddfb/treehouse-compose/src/commonMain/kotlin/app/cash/treehouse/compose/applier.kt#L58


 * appear directly in the protocol.
 */
private sealed class ChildrenNode(id: Long) : Node(id, -1) {
  abstract val tag: Int

  class Intermediate : ChildrenNode(-1) {
    override var tag = -1
  }

  class Root : ChildrenNode(RootId) {
    // TODO We cannot actually guarantee this is the tag that the display root uses for children.
    override val tag get() = RootChildrenTag
  }
}


73066eee9b40a92fe47d2cbd0e4debb094514ecb

Easy testing of business logic

I’d like to build out test infrastructure. A sketch of how it might work:

fun test() {
  val renderer = TreehouseRenderer()

  renderer.compose {
    Counter(100)
  }
  assertThat((renderer.root[0] as SunspotButton).text).isEqualTo("-1")
  assertThat((renderer.root[1] as SunspotText).text).isEqualTo("100")

  (renderer.root[0] as SunspotButton).onClick()

  renderer.recompose() // Necessary?
  assertThat((renderer.root[1] as SunspotText).text).isEqualTo("99")
}

The renderer’s root is a Kotlin value object of the most recently composed view.

We could make it so onClick() automatically recomposes, or we could make it explicit.

Remove the need for two frames to happen!

Remove the need for two frames to happen!
I think this is because of the diff-sender is a hot loop that immediately reschedules
itself on the clock. This schedules it ahead of the coroutine which applies changes and
so we need to trigger an additional frame to actually emit the change's diffs.

https://github.com/square/treehouse/blob/ed670d9e8fd25a8b54c3fdac54cf284119afd6a1/treehouse/treehouse-compose/src/commonTest/kotlin/app/cash/treehouse/compose/treehouseTesting.kt#L25


/*
 * Copyright (C) 2021 Square, Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package app.cash.treehouse.compose

import androidx.compose.runtime.BroadcastFrameClock
import androidx.compose.runtime.withFrameMillis
import kotlinx.coroutines.CoroutineStart.UNDISPATCHED
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch

suspend fun BroadcastFrameClock.awaitFrame() {
  // TODO Remove the need for two frames to happen!
  //  I think this is because of the diff-sender is a hot loop that immediately reschedules
  //  itself on the clock. This schedules it ahead of the coroutine which applies changes and
  //  so we need to trigger an additional frame to actually emit the change's diffs.
  repeat(2) {
    coroutineScope {
      launch(start = UNDISPATCHED) {
        withFrameMillis { }
      }
      sendFrame(0L)
    }
  }
}

89fd63b61c759f7379e3802561e5702017e2defa

Remove "-configuration Debug" https://github.com/cashapp/treehouse/issues/119

Remove "-configuration Debug" #119

https://github.com/cashapp/treehouse/blob/438e754ee3eb7f83822db52dcf34bbce48cb80a6/.github/workflows/build.yaml#L34

        run: ./gradlew build --parallel

      - name: Build Xcode samples
        # TODO Remove "-configuration Debug" https://github.com/cashapp/treehouse/issues/119
        run: |
          cd samples/counter/ios/app
          pod install
          xcodebuild -workspace CounterApp.xcworkspace -scheme CounterApp -configuration Debug -destination 'platform=iOS Simulator,name=iPhone 12,OS=latest'

      - run: ./gradlew -p treehouse publish
        if: ${{ github.ref == 'refs/heads/trunk' && github.repository == 'cashapp/treehouse' }}
        env:
          ORG_GRADLE_PROJECT_SONATYPE_NEXUS_USERNAME: ${{ secrets.SONATYPE_NEXUS_USERNAME }}
          ORG_GRADLE_PROJECT_SONATYPE_NEXUS_PASSWORD: ${{ secrets.SONATYPE_NEXUS_PASSWORD }}

      - name: Deploy docs to website
        if: ${{ github.ref == 'refs/heads/trunk' && github.repository == 'cashapp/treehouse' }}
        uses: JamesIves/github-pages-deploy-action@releases/v3
        with:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

6b4f1c4c17392e711570baec5f0bb0b9eeb99a53

Schema Versioning

Schemas change over time. We need to design a safe way for composers and displays to interop safely if their schemas are on different versions. We should also consider tools to help prevent unexpected incompatible schema changes.

I recommend we enforce that the composer code is never older than the display code. Supporting forwards and backwards compatibility is way harder than just backwards compatibility. Developers are already familiar with only backwards compatibility; servers are never older than their clients. If the composer code is too old, we get new code by either updating dependencies (build time) or downloading (runtime).

I recommend we require explicit schema versions. I think a single integer like Android API versions should be enough. I don’t expect we’ll ever want to change schema version 3 after schema version 4 is created. We can make the schema version an attribute of the @Schema annotation, and build some process or mechanism to remind people to bump this.

I recommend we make the display’s version available to the composer so it can fall back. For example, a composer could emit a stock price to a TextLabel if the display doesn’t support a StockChart node.

To prevent unexpected schema versions we could generate a JSON representation of the schema as a project resource, generated when we generate code. The Gradle plugin could check whether the JSON representation of the current project is compatible with another version. We do this with japicmp in Okio and it’s saved us enough times to be worth the investment.

something?

something?

https://github.com/square/treehouse/blob/83e82e272450019fca385a8a024a1365c5122081/treehouse-gradle/src/test/fixture/counter/browser/src/main/kotlin/example/browser/counter/widgets.kt#L16


package example.browser.counter

import example.counter.widget.CounterBox
import example.counter.widget.CounterButton
import example.counter.widget.CounterText
import org.w3c.dom.HTMLButtonElement
import org.w3c.dom.HTMLElement
import org.w3c.dom.HTMLSpanElement

class HtmlSunspotBox(
  override val value: HTMLElement,
) : SunspotBox<HTMLElement> {
  override val children = HTMLElementChildren(value)

  override fun orientation(orientation: Boolean) {
    // TODO something?
  }
}

class HtmlSunspotText(
  override val value: HTMLSpanElement,
) : SunspotText<HTMLElement> {
  override fun text(text: String?) {
    value.textContent = text
  }

  override fun color(color: String) {
    value.style.color = color
  }
}

class HtmlSunspotButton(
  override val value: HTMLButtonElement,
) : SunspotButton<HTMLElement> {
  override fun text(text: String?) {
    value.textContent = text
  }

  override fun enabled(enabled: Boolean) {
    value.disabled = !enabled
  }

  override fun onClick(onClick: (() -> Unit)?) {
    value.onclick = if (onClick != null) {
      { onClick() }
    } else {
      null
    }
  }
}

562dfc5d38012c9fda420e57fe22d549b12c54e2

Enable use with the native backend

No artifacts build for native yet. For now we'll only be able to enable the display and protocol modules. Compiler is blocked on landing changes in the Compose compiler (or in JetBrains' fork).

Multiple children on a node

Notes from meeting in early January:

Components with multiple lists of children
  TLDR change:
    Currently we emit children directly inside the emit() call,

      emit(
        ...,
        children = {
          children()
        },
      )

    For each child group, change that to emit an intermediate Children node

      emit(
        ...,
        children = {
          Children(id = 1) {
            children1()
          }
          Children(id = 2) {
            children2()
          }
        },
      )

    Erase the Children box at the protocol layer by using its ID as the child index.

     class Screen {
       headerChildren: List<Any>
       bodyChildren: List<Any>
     }

     We want to rewrite this as if it were coded as

     class Screen {
       headerChildren: HeaderChildrenBox
       bodyChildren: BodyChildrenBox
     }
     class HeaderChildrenBox {
       list: List<Any>
     }
     class BodyChildrenBox {
       list: List<Any>
     }

     (this should be at the wire-protocol level only, with some affordances for receiving diffs)

Remove the need for two frames to happen!

Remove the need for two frames to happen!
I think this is because of the diff-sender is a hot loop that immediately reschedules
itself on the clock. This schedules it ahead of the coroutine which applies changes and
so we need to trigger an additional frame to actually emit the change's diffs.

https://github.com/square/treehouse/blob/e64e5d814ed8fa1d1ccf9eeec0363c72119d4968/treehouse-compose/src/commonTest/kotlin/app/cash/treehouse/compose/TreehouseCompositionTest.kt#L108


package app.cash.treehouse.compose

import androidx.compose.runtime.BroadcastFrameClock
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameMillis
import app.cash.treehouse.protocol.ChildrenDiff
import app.cash.treehouse.protocol.ChildrenDiff.Companion.RootChildrenTag
import app.cash.treehouse.protocol.ChildrenDiff.Companion.RootId
import app.cash.treehouse.protocol.Diff
import app.cash.treehouse.protocol.Event
import app.cash.treehouse.protocol.PropertyDiff
import example.treehouse.compose.Button
import kotlinx.coroutines.CoroutineStart.UNDISPATCHED
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import kotlinx.coroutines.yield
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.fail

class TreehouseCompositionTest {
  @Test fun protocolSkipsLambdaChangeOfSamePresence() = runTest {
    val clock = BroadcastFrameClock()
    val diffs = ArrayDeque<Diff>()
    val composition = TreehouseComposition(this + clock, diffs::add)

    var state by mutableStateOf(0)
    composition.setContent {
      Button(
        "state: $state",
        onClick = when (state) {
          0 -> { { state = 1 } }
          1 -> { { state = 2 } }
          2 -> { null }
          3 -> { null }
          else -> fail()
        }
      )
    }

    clock.awaitFrame()
    assertEquals(
      Diff(
        childrenDiffs = listOf(
          ChildrenDiff.Insert(RootId, RootChildrenTag, 1L, 3 /* button */, 0),
        ),
        propertyDiffs = listOf(
          PropertyDiff(1L, 1 /* text */, "state: 0"),
          PropertyDiff(1L, 2 /* onClick */, true),
        ),
      ),
      diffs.removeFirst()
    )

    // Invoke the onClick lambda to move the state from 0 to 1.
    composition.sendEvent(Event(1L, 2, null))
    yield() // Allow state change to be handled.

    clock.awaitFrame()
    assertEquals(
      Diff(
        childrenDiffs = listOf(),
        propertyDiffs = listOf(
          PropertyDiff(1L, 1 /* text */, "state: 1"),
        ),
      ),
      diffs.removeFirst()
    )

    // Invoke the onClick lambda to move the state from 1 to 2.
    composition.sendEvent(Event(1L, 2, null))
    yield() // Allow state change to be handled.

    clock.awaitFrame()
    assertEquals(
      Diff(
        childrenDiffs = listOf(),
        propertyDiffs = listOf(
          PropertyDiff(1L, 1 /* text */, "state: 2"),
          PropertyDiff(1L, 2 /* text */, false),
        ),
      ),
      diffs.removeFirst()
    )

    // Manually advance state from 2 to 3 to test null to null case.
    state = 3
    yield() // Allow state change to be handled.

    clock.awaitFrame()
    assertEquals(
      Diff(
        childrenDiffs = listOf(),
        propertyDiffs = listOf(
          PropertyDiff(1L, 1 /* text */, "state: 3"),
        ),
      ),
      diffs.removeFirst()
    )

    composition.cancel()
  }

  private suspend fun BroadcastFrameClock.awaitFrame() {
    // TODO Remove the need for two frames to happen!
    //  I think this is because of the diff-sender is a hot loop that immediately reschedules
    //  itself on the clock. This schedules it ahead of the coroutine which applies changes and
    //  so we need to trigger an additional frame to actually emit the change's diffs.
    repeat(2) {
      coroutineScope {
        launch(start = UNDISPATCHED) {
          withFrameMillis {
          }
        }
        sendFrame(0L)
      }
    }
  }
}

0d06162f3d11fca4d78e545526e28997f45c7495

Sort numbers generated in NodeFactory#create

Components in NodeFactory#create are currently sorted by the order in which they're declared in the schema annotation's array. It'd be visually nice to sort them by their Node value instead.

image

TreeNodes are generated for types that aren't actually components

I'm not sure if this is actually a bug so feel free to correct me. I noticed that treehouse is generating TreeNodes for enums even when they're not supposed to be used like a component in the view hierarchy. Here's an example from Cash App:

// Enum:
@Node(3)
@Serializable
@SerialName("DialogAction")
data class DialogAction(...) : Action() {
  @Node(14)
  enum class ButtonStyle {
    @Property(1)
    STANDARD,

    @Property(2)
    DESTRUCTIVE,
  }

// Generated code:
public interface ButtonStyle<T : Any> : TreeNode<T> {
  public override fun apply(diff: PropertyDiff): Unit {
    when (val tag = diff.tag) {
      else -> throw IllegalArgumentException("Unknown tag $tag")
    }
  }
}

The generated node also becomes part of the NodeFactory. Other examples in our project include BoxLayout, BoxLayoutDirection, and BoxLayoutItem which are all inner classes of Box. Is this expected?

Schema-based testing on JS is failing at Compose rc01

Manifested in #123 before the test was disabled.

Our composable looks roughly like:

@Composable
public fun Text(text: String?, color: String = "black"): Unit {
  ComposeNode<SchemaNode, Applier<SchemaNode>>(
      factory = { SchemaNode(2) },
      update = {
        set(text) { .. }
        set(color) { appendDiff(PropertyDiff(.., color)) }
      },
      )
}

When Applier.onEndChanges fires after evaluating Text, this.color is null on the node which should be impossible as there's always a non-null value to be set. On the JVM it's correctly set to black.

Best guess here is that default argument values are somehow not being honored/applied with some compiler change. We need a reproducer in vanilla Compose, a bug in upstream Jetpack issue tracker, and to ping JetBrains.

Remove "-configuration Debug" https://github.com/square/treehouse/issues/119

Remove "-configuration Debug" #119

https://github.com/square/treehouse/blob/49501af708d619646a3fe0d7d18fcfd5148abf87/.github/workflows/build.yaml#L34

          java-version: 14

      - run: ./gradlew -p treehouse build dokkaHtmlMultiModule --parallel

      - name: Build samples
        run: ./gradlew build --parallel

      - name: Build Xcode samples
        # TODO Remove "-configuration Debug" https://github.com/square/treehouse/issues/119
        run: |
          ./gradlew :samples:counter:ios:shared:generateDummyFramework
          cd samples/counter/ios/app
          pod install
          xcodebuild -workspace CounterApp.xcworkspace -scheme CounterApp -configuration Debug -destination 'platform=iOS Simulator,name=iPhone 12,OS=latest'

      - run: ./gradlew -p treehouse publish
        if: ${{ github.ref == 'refs/heads/trunk' && github.repository == 'square/treehouse' }}

47a41e7749519577e834954fc40ccf374d58bf33

Eliminate TreehouseScope, surface applier into public API

There are a few motivations here:

  1. Reduce the verbosity of repeating TreehouseScope as a receiver on functions.
  2. With #14 we often won't need the protocol, such as when doing testing or when running in the same language/system as the display-side. We should switch to an applier that solely targets the testing mechanism, or directly interacts with the display widgets.

A follow-up from #89 and #1.

parameter type list?

parameter type list?

https://github.com/square/treehouse/blob/a69db8bccebf338f311b84fd0e9522ea9c23822b/treehouse-schema-parser/src/main/kotlin/app/cash/treehouse/schema/parser/schema.kt#L34


package app.cash.treehouse.schema.parser

import kotlin.reflect.KClass
import kotlin.reflect.KType

data class Schema(
  val name: String,
  val `package`: String,
  val nodes: List<Node>,
)

data class Node(
  val tag: Int,
  val className: KClass<*>,
  val traits: List<Trait>,
)

sealed class Trait {
  abstract val name: String
  abstract val tag: Int
  abstract val defaultExpression: String?
}

data class Property(
  override val name: String,
  override val tag: Int,
  val type: KType,
  override val defaultExpression: String?,
) : Trait()

data class Event(
  override val name: String,
  override val tag: Int,
  // TODO parameter type list?
  override val defaultExpression: String?,
) : Trait()

data class Children(
  override val name: String,
  override val tag: Int,
) : Trait() {
  override val defaultExpression: String? get() = null
}

ee850f2de373f9e665bb886f42503518ebb14cb7

Kotlin/Native only supports a single-threaded use

actual thread local lol

https://github.com/square/treehouse/blob/d01e97892ebdb7593d9b68ceba4f93678dfd8f84/treehouse/compose/runtime/src/nativeMain/kotlin/androidx/compose/runtime/ActualNative.native.kt#L26


/*
 * Copyright (C) 2021 Square, Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package androidx.compose.runtime

import androidx.compose.runtime.snapshots.SnapshotMutableState
import kotlinx.atomicfu.atomic
import kotlinx.coroutines.yield
import kotlin.native.identityHashCode
import kotlin.system.getTimeNanos
import kotlin.time.ExperimentalTime

// TODO actual thread local lol
internal actual open class ThreadLocal<T> actual constructor(
  initialValue: () -> T
) {
  private var value: T = initialValue()

  actual fun get(): T = value

  actual fun set(value: T) {
    this.value = value
  }
}

actual class AtomicReference<V> actual constructor(value: V) {
  private val delegate = atomic(value)

  actual fun get(): V = delegate.value

  actual fun set(value: V) {
    delegate.value = value
  }

  actual fun getAndSet(value: V): V =
    delegate.getAndSet(value)

  actual fun compareAndSet(expect: V, newValue: V): Boolean =
    delegate.compareAndSet(expect, newValue)
}

internal actual fun identityHashCode(instance: Any?): Int =
  instance.identityHashCode()

actual annotation class TestOnly

actual inline fun <R> synchronized(lock: Any, block: () -> R): R =
  block()

actual val DefaultMonotonicFrameClock: MonotonicFrameClock = MonotonicClockImpl()

@OptIn(ExperimentalTime::class)
private class MonotonicClockImpl : MonotonicFrameClock {
  override suspend fun <R> withFrameNanos(
    onFrame: (Long) -> R
  ): R {
    yield()
    return onFrame(getTimeNanos())
  }
}

internal actual object Trace {
  actual fun beginSection(name: String): Any? {
    return null
  }

  actual fun endSection(token: Any?) {
  }
}

actual annotation class CheckResult actual constructor(actual val suggest: String)

internal actual fun <T> createSnapshotMutableState(
  value: T,
  policy: SnapshotMutationPolicy<T>
): SnapshotMutableState<T> =
  SnapshotMutableStateImpl(value, policy)

//fixme: not actually thread local
internal actual class SnapshotThreadLocal<T> actual constructor() {
  private var value: T? = null

  actual fun get(): T? = value
  actual fun set(value: T?) {
    this.value = value
  }
}

8195599792977b4202eb274013bb5ec951861e2e

Use a custom discriminator for children diff sealed types

Right now we waste bytes on huge strings!

{"childrenDiffs":[["app.cash.treehouse.protocol.ChildrenDiff.Insert",{"id":0,"tag":1,"childId":1,"kind":3,"index":0}],["app.cash.treehouse.protocol.ChildrenDiff.Insert",{"id":0,"tag":1,"childId":2,"kind":2,"index":1}],["app.cash.treehouse.protocol.ChildrenDiff.Insert",{"id":0,"tag":1,"childId":3,"kind":3,"index":2}]],"propertyDiffs":[{"id":1,"tag":1,"value":["kotlin.String","-1"]},{"id":1,"tag":2,"value":["kotlin.Boolean",true]},{"id":2,"tag":1,"value":["kotlin.String","0"]},{"id":3,"tag":1,"value":["kotlin.String","+1"]},{"id":3,"tag":2,"value":["kotlin.Boolean",true]}]}

atomics if compose becomes multithreaded?

atomics if compose becomes multithreaded?

https://github.com/square/treehouse/blob/64852e2213f4a8c35bf9e79f8a8c67399def1bc7/treehouse/treehouse-compose/src/commonMain/kotlin/app/cash/treehouse/compose/applier.kt#L132


 * ```
 */
internal class ProtocolApplier(
  private val onDiff: (Diff) -> Unit,
) : AbstractApplier<ProtocolNode>(ProtocolChildrenNode.Root()) {
  private var childrenDiffs = mutableListOf<ChildrenDiff>()
  private var propertyDiffs = mutableListOf<PropertyDiff>()

  val scope: TreehouseScope = RealTreehouseScope()
  inner class RealTreehouseScope : TreehouseScope {
    // TODO atomics if compose becomes multithreaded?
    private var nextId = RootId + 1
    override fun nextId() = nextId++

    override fun appendDiff(diff: PropertyDiff) {
      propertyDiffs.add(diff)
    }
  }

  override fun onEndChanges() {
    val existingChildrenDiffs = childrenDiffs
    val existingPropertyDiffs = propertyDiffs
    if (existingPropertyDiffs.isNotEmpty() || existingChildrenDiffs.isNotEmpty()) {
      childrenDiffs = mutableListOf()
      propertyDiffs = mutableListOf()

      val diff = Diff(
        childrenDiffs = existingChildrenDiffs,
        propertyDiffs = existingPropertyDiffs,
      )
      onDiff(diff)
    }
  }

  val nodes = mutableMapOf(root.id to root)

  override fun insertTopDown(index: Int, instance: ProtocolNode) {

42a086e6bbcc8d4ff5967da54cea99f44fffa6e8

check there is a JVM target in an afterEvaluate

check there is a JVM target in an afterEvaluate

https://github.com/square/treehouse/blob/128121095da6c37a7bb3954ff74afa69aec87c0c/treehouse-gradle/src/main/kotlin/app/cash/treehouse/gradle/TreehouseSchemaPlugin.kt#L25


      applied = true

      val kotlin = project.extensions.getByType(KotlinJvmProjectExtension::class.java)
      kotlin.target.applySchemaAnnotationDependency()
    }
    project.plugins.withId("org.jetbrains.kotlin.multiplatform") {
      applied = true

      val kotlin = project.extensions.getByType(KotlinMultiplatformExtension::class.java)
      kotlin.targets.all { it.applySchemaAnnotationDependency() }

      // TODO check there is a JVM target in an afterEvaluate
    }

    project.afterEvaluate {
      check(applied) {
        "Treehouse schema plugin requires the Kotlin JVM or multiplatform plugin to be applied."
      }
    }
  }

  private fun KotlinTarget.applySchemaAnnotationDependency() {
    compilations.getByName("main") { compilation ->
      compilation.dependencies {
        api("app.cash.treehouse:treehouse-schema-annotations:$treehouseVersion")
      }
    }
  }

35d991ac1a517574b24fad537d9e34e89b76df57

We cannot actually guarantee this is what index the display root uses for childr...

We cannot actually guarantee this is what index the display root uses for children.

https://github.com/square/treehouse/blob/266e3ff50cd5425e257ad9d3f178157479a864d1/treehouse-compose/src/commonMain/kotlin/app/cash/treehouse/compose/applier.kt#L57


  fun appendDiff(diff: PropertyDiff)
}

/**
 * A synthetic node which allows the applier to differentiate between multiple groups of children.
 *
 * Compose's tree assumes each node only has single list of children. Or, put another way, even if
 * you apply multiple children Compose treats them as a single list of child nodes. In order to
 * differentiate between these children lists we introduce synthetic nodes. Every real node which
 * supports one or more groups of children will have one or more of these synthetic nodes as its
 * direct descendants. The nodes which are produced by each group of children will then become the
 * descendants of those synthetic nodes.
 *
 * This function is named weirdly to prevent normal usage since bad things will happen.
 *
 * @see ProtocolApplier
 */
@Composable
fun `-SyntheticChildren`(index: Int, content: @Composable () -> Unit) {
  ComposeNode<ChildrenNode.Intermediate, Applier<Node>>(
    factory = ChildrenNode::Intermediate,
    update = {
      set(index) {
        childrenIndex = index
      }
    },
    content = content,
  )
}

/**
 * A node which exists in the tree to emulate supporting multiple children sets but which does not
 * appear directly in the protocol.
 */
private sealed class ChildrenNode(id: Long) : Node(id, -1) {
  abstract val childrenIndex: Int

  class Intermediate : ChildrenNode(-1) {
    override var childrenIndex = -1
  }

  class Root : ChildrenNode(TreeDiff.RootId) {
    // TODO We cannot actually guarantee this is what index the display root uses for children.
    override val childrenIndex get() = 1
  }
}

open class Node(
  val id: Long,
  val type: Int,

e8efaff02cc5d7c3514092993d12d2d6fda3a356

Scope opt-in compiler flags more tightly

Right now they apply unconditionally and cause a bunch of warnings in the build.

E.g., ExperimentalComposeApi probably only needs to be in one or two modules.

Remove "-configuration Debug" https://github.com/cashapp/treehouse/issues/119

Remove "-configuration Debug" #119

https://github.com/cashapp/treehouse/blob/7a11fe562ed5f3553b92daed4093a9828ebf1e65/.github/workflows/build.yaml#L34

        run: ./gradlew build --parallel

      - name: Build Xcode samples
        # TODO Remove "-configuration Debug" https://github.com/cashapp/treehouse/issues/119
        run: |
          cd samples/counter/ios/app
          pod install
          xcodebuild -workspace CounterApp.xcworkspace -scheme CounterApp -configuration Debug -destination 'platform=iOS Simulator,name=iPhone 12,OS=latest'

      - run: ./gradlew -p treehouse publish
        if: ${{ github.ref == 'refs/heads/trunk' && github.repository == 'cashapp/treehouse' }}

14ff30d2384171b9f9cbc4eadb294a71f65e9c08

Enable Compose's tests

They were disabled because they depended on too much Android. Since our pressure to remove the default frame clock, they should be able to run anywhere (and hopefully on every platform we support).

Validate @Children nodes to be of type List<Any>

The doc of @Children says it should only be applied to nodes of type List<Any> but it isn't actually validated, allowing usages on any types.

/**
 * Annotates a [Node] property as representing child nodes which are contained within the enclosing
 * node. Each property in a [Node] class must have a unique [value] among all [@Children][Children]
 * annotations in the class. The type of the property must be `List<Any>`.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.