briskml / brisk-reconciler Goto Github PK
View Code? Open in Web Editor NEWReact.js-like reconciler implemented in OCaml/Reason
License: MIT License
React.js-like reconciler implemented in OCaml/Reason
License: MIT License
We need a solid test suite. I don't think there exists a better place to take tests from than react js repo. We are probably going to need ot modify a fair share of them and discard some (both because brisk doesn't aim for feature parity with react and that we might have different semantics for some cases.)
(Related to the nesting-reconcilers discussion in #30, and the compositional component discussion in revery: revery-ui/revery#489) Also inspired by @jordwalke 's exploration on statically typed React trees: https://github.com/reasonml/reason-react/tree/StaticReactExperiment/explorations
There have been cases that have come up where it'd be nice to make some constraints on the tree at compile-time:
<Transform />
Based on discussion with @wokalski , this seems doable with a PPX
FlexLayout
widget, that can only take Row
, Column
as first-level children. Other examples could be a <Menu>
that only allows <MenuItem />
as children, etc.I think this may be something that could be supported via nested reconcilers - @wokalski mentioned that perhaps we could model this as a type constraint where element('a)
is generic, element(menuComponent)
is specific to a menu, etc.
Not sure if this is a bug or not, but when passing items from one component to another one ends up with no items, despite them being available in e.g. the initialState
-function.
let initialState = items => {
Console.log(("We've got some items here:", items));
items;
};
let%component childItem = (~items, ()) => {
let%hook (allItems, setItems) =
Hooks.reducer(~initialState=initialState(items), (value, _) => value);
// Works when using the effect-hook
/* let%hook () = */
/* Hooks.effect( */
/* OnMountAndIf((!=), items), */
/* () => { */
/* setItems(items); */
/* None; */
/* }, */
/* ); */
<View>
{allItems
|> List.map(item => <Text style=Style.[color(Colors.red)] text=item />)
|> React.listToElement}
</View>;
};
let%component container = () => {
let%hook (items, setItems) = React.Hooks.state([]);
let%hook () =
Hooks.effect(
OnMount,
() => {
setItems(_prev => ["One", "Two", "Three"]);
None;
},
);
<childItem items />;
};
reason-reactify has some great examples. It'd be great to bring them over to Brisk.
After upgrading to latest brisk - we experienced a couple regressions in our functionality:
These occurred after the update to the immutable hooks API: revery-ui/revery@eda0fe6
The issue was a problem in our code - we had long-lived closures that held on to set
functions past re-renders. For #346 , for example, a fix was to switch to a shorter-lived effect
hook.
However, it wasn't very diagnosable - we just started seeing unexpected behavior when we used the set
functions in this way. It'd be helpful if we could at least detect this case - that we're calling a stale set
and throw a descriptive error - like how the React devtools warn.
A message like ERROR: Stale setter used for reducer hook; check that you aren't holding a setter in a long-lived function
would be helpful to catch the error close to the source.
Those lines look suspicious:
https://github.com/briskml/brisk-reconciler/blob/master/lib/Brisk_reconciler_internal.re#L510-L518
We don't recurse through the whole tree to capture effects, just one level down. Does it mean that on updates we call each effect twice?
Reading through #14 made me think about this - eventually, for Revery and beyond - I'd like to be able to have a 'time travel' development experience. Like Redux, but maybe without necessitating keeping a global app state.
I was curious if it'd be possible to hold on to past rendered
trees, and call executeHostViewUpdates
on old versions in order to rewind the tree to an earlier state.
The 'developer experience' I'm thinking about would be a sort of slider where you could rewind to any particular point in time, and see the UI in that 'rewound' state. I'm interested in this especially in the context of debugging animations (ie, frames where we hit a discontinuity). Implementation-wise, in debug mode, we'd record 'snapshots' of the rendered tree and hold them in memory for some amount of limit.
One challenge I see is that I'm not sure how 'effects' would play into this...
The nice thing about this developer experience - if it works with Brisk - is that this DX would apply to any brisk-reconciler powered app - which would be amazing ๐
I love the idea of this 'time travel' debugging experience, but it's always been finicky to set up and use - but I hypothesize the functional nature of Reason/Brisk would at least bring this closer to being a usable reality.
Would this be doable as-is with the current reconciler infrastruture, @wokalski ? Or do you see any deltas?
let%component make = (type a, ~someProp: a, ...) =>
...
is rejected with the error fun expected
because type a
results in a Pexp_newtype
, while a Pexp_fun
is expected.
And because of #53 it's not possible to use a stand-alone type annotation either.
This was desirable design decision but it's both inconsistent with React.js and only causes headaches. If someone doesn't want to fire it on mount they can add a piece of state like isFirstRender
which will be initially true.
We're probably going to change the API for hooks a little bit. I.e. instead of annotating the hooks on the last call we're going to either:
Hooks.ignore(hooks)
(better name suggestions are welcome) (hooks, <Component prop />)
getDerivedStateFromProps
and should be more performant (thanks, immutability))let%component myComponent - (~component, (), hooks) => { ... }
will expand to (approximately)
let myComponent ={
let component = Expert.component("myComponent");
(~component, ()) =>
component(hooks => { ... })
);
hence the locally defined component
will be shadowed by the prop/labelled argument, resulting in a type error. The PPX should instead use an invalid identifier name, like brisk-component
, so it cannot be shadowed.
Also do the same for %hook
, which while it uses a more cryptic name also has a microscopic chance of being shadowed.
I just had a very random idea.
We could expose Brisk.Static
renderers which would contain renderers for static output (i.e. Static wouldn't expose update, flushPendingUpdates, shouldUpdate, or any other update related stuff). The rationale is that I feel like it could be cool to use a react like component system to build static content like websites, md files, formatted text, etc.
The index of the child to remove would be useful for rendering to some kinds of platforms. For example, React Native bases its removals on indices: https://github.com/facebook/react-native/blob/1af390be19000b2756fc646e72f152dd383a0bb1/React/Modules/RCTUIManager.m#L845
If brisk-reconciler has this information easily available, it would be convenient to pass it along. If not, it's not a big deal. The consumer of brisk-reconciler can create a data structure to store the node positions and update it in insertNode
and moveNode
.
There were some docs in reason reactify which might be worth stealing from there.
Calls to dispatch
currently results in deferred execution of the reducer
and does not guarantee that messages are processed in the order they were dispatched. This can cause subtle bugs when the reducer has side-effects that depend on ordering.
I understand the motivation for this is to have potentially blocking calls in the reducer not block the UI thread. As I understand it, the deferred execution of reducer
is still run on the UI thread however, but even if it wasn't I think it would be better to have a separate mechanism for deferring side-effects and dispatching messages back to the reducer
to update state. Similarly to how ReasonReact has the reducer
return a variant Update | SideEffect
, or how Elm has the update
function return a tuple of the state and a Cmd
.
I therefore propose first changing the reducer to execute immediately, but still have the rendering deferred, and then to consider a separate mechanism for async side-effects to be implemented later.
Type annotations on let%component
functions are transformed to apply to only the return value, not the entire function.
module ShouldAllowComponentProp = {
let%component make = (~component, (), hooks): element(node) =>
(<Div> component </Div>, hooks);
}
Transforms to:
module ShouldAllowComponentProp =
struct
let make =
let brisk-component =
Brisk_reconciler.Expert.component ~useDynamicKey:false
"test/Components.re.ShouldAllowComponentProp.make" in
fun ?(key= Brisk_reconciler.Key.none) ->
fun ~component ->
fun () ->
brisk-component ~key
(fun hooks ->
(((let brisk-component = Div.make in
((brisk-component
~children:(Brisk_reconciler.Expert.jsx_list
[component]) ())
[@JSX ])), hooks) : node element))
end
Which produces the type error:
File "test/Components.re", line 239, characters 4-35:
239 | (<Div> component </Div>, hooks);
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Error: This expression has type 'a * 'b
but an expression was expected of type node element
module ShouldAllowComponentProp = {
let%component make
: (~key:int=?, ~component: element(node), unit) => element(node)
= (~component, (), hooks) =>
(<Div> component </Div>, hooks);
}
Transforms to:
module ShouldAllowComponentProp =
struct
let make =
let brisk-component =
Brisk_reconciler.Expert.component ~useDynamicKey:false
"test/Components.re.ShouldAllowComponentProp.make" in
fun ?(key= Brisk_reconciler.Key.none) ->
brisk-component ~key
(fun ~component ->
fun () ->
fun hooks ->
((let brisk-component = Div.make in
((brisk-component
~children:(Brisk_reconciler.Expert.jsx_list
[component]) ())
[@JSX ])), hooks) : ?key:int ->
component:node element ->
unit -> node element)
end
Which produces the error:
File "test/Components.re", line 240, characters 4-62:
240 | (~component, (), hooks) => (<Div> component </Div>, hooks);
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Error: This function should have type
?key:int -> component:node element -> unit -> node element
but its first argument is labelled ~component
This change could potentially simplify the hook ppx and the types in render function.
If any of the esy.lock's is not up to date we should warn.
Additionally I'm wondering if it's possible to put all the locks and package-ci-*.jsons in a ci
directory and have it still work.
Was just working on porting Oni2 over to the latest Revery + Brisk reconciler, and reminded me of the context issue. We're using context right now to provide a context-based ThemeProvider
.
A potential API could look like:
createContext: 'a -> context('a)
Hooks.useContext: context('a) -> 'a
getProvider: context('a) -> providerComponent('a)
I believe handling the typing correctly in terms of passing the nested context down will be challenging - so we might need to alter the API to make it workable in a strongly-typed environment w/o resorting to Obj.magic
.
Not a blocker, I'll workaround it, but just wanted to log an issue to start the discussion.
The update logic is already implemented in the github action but unfortunately there's some problem with authentication and this step doesn't pass. It'd be great to move it to azure pipelines together with the rest of CI.
Certain dependencies don't build on windows like ppx_deriving
which we're using for tests. Adding the platform itself to the CI pipeline is very easy (17b7977) however the build fails.
The impact is that nodes can end up on screen in a different order than the developer expects.
I wrote this test to illustrate the issue:
(
"Test transitioning from empty list to non-empty list",
`Quick,
() =>
render(
Components.(<Div> <Box title="ImABox0" /> {listToElement([])} </Div>),
)
|> executeSideEffects
|> reset
|> update(
Components.(
<Div>
<Box title="ImABox0" />
{listToElement([<Box title="ImABox1" />])}
</Div>
),
)
|> executeSideEffects
|> expect(
~label="It appends the 2nd node",
[
Implementation.BeginChanges,
UnmountChild(div, box("ImABox0")),
MountChild(div, box("ImABox0"), 0),
MountChild(div, box("ImABox1"), 1),
CommitChanges,
],
)
|> ignore,
),
Expected: The new node ("ImABox1") is placed at position 1
.
Actual: The new node ("ImABox1") is placed at position 0
.
The current dance with:
let c = component("name");
let somethingElse = (~prop, ~prop, ()) => component(hooks => ...
is really annoying. It feels like using something very rough unpolished. We should strive to make possible the following:
let somethingElse = component((~prop, ~prop, ()) => ...)
We could instead move mountLog
to TestHelpers.re and pass a ref
to mountLog
in root when insertNode
or moveNode
is called. It'll remove the need for resetting the state in tests which is error prone.
Currently we call side effects after flushing pending effects which is not the desired behaviour.
It's often the case that the root is of a different type than the children elements. It happens with toolbar (i.e. toolbar is a parent and items are children), it happens with SegmentedControl
. It currently requires some strange wrapping. We should have a separate function and type for the root (if a given reconciler requires it).
The set of requirements here will become clearer if we implement a reconciler for navigation which should be a stress test for the current abstraction.
Toolbar is also an interesting case where there are 3 types of elements, the window where toolbar is mounted, toolbar which mounts items and items themselves which render normal views behind the scenes. A lot going on.
Currently, there's additional boilerplate involved when using JSX; users have to manually implement createElement
which is the function call that JSX is mapped to in Reason. Such function call is decorated with the [@JSX]
attribute which allows any library to implement custom logic for the transformation.
We'd like to leverage it to remove the boilerplate. The specific implementation of this preprocessor will depend on the component creation API. A potential lightweight first class module based API like this is possible.
Another important question is if we want to use mainly upper case or lower case JSX. I am in favour of keeping the JSX as close to component definitions as possible. I.e. if we end up with the API above, I think we should have the lower case JSX. If we have a functor api for instance, we should use upper case JSX.
Hello !
I wrote a component like this:
let%component make = (~render, ()) => {
let%hook (currentRoute, setCurrentRoute) =Hooks.state(Store.route^);
let%hook () =
Hooks.effect(
OnMount,
() => {
Store.subscribe((_, newRoute) => setCurrentRoute(_ => newRoute));
None;
},
);
<View> {render(state.currentRoute)} </View>;
};
It's supposed to rerender every time the route update. It works on the first update of my route, and then the component doesn't update at all. I added some log inside the setCurrentRoute
, it is triggered, but component still not rerender.
Someone suggested that I make it with a reducer and it worked perfectly:
type action =
| Route(string);
type state = {currentRoute: string};
let reducer = (action, state) => {
switch (action) {
| Route(route) => {currentRoute: route}
};
};
let%component make = (~render, ()) => {
let%hook (state, dispatch) =
Hooks.reducer(~initialState={currentRoute: Store.route^}, reducer);
let%hook () =
Hooks.effect(
OnMount,
() => {
Store.subscribe((_, newRoute) => dispatch(Route(newRoute)));
None;
},
);
<View> {render(state.currentRoute)} </View>;
};
Windows caching needs to be fixed. 4.06 and 7 can be removed.
I got the following the error when I tried building the lambda-term
example
info esy 0.6.7 (using esy.json)
info checking https://github.com/ocaml/opam-repository for updates...
info checking https://github.com/esy-ocaml/esy-opam-override for updates...
info resolving esy packages: done
info solving esy constraints: done
info resolving npm packages: done
info fetching: done
info installing: done
info building @opam/ocamlbuild@opam:0.14.0@6ac75d03
info building @opam/ocamlfind@opam:1.8.1@ff07b0f9
error: build failed with exit code: 1
build log:
# esy-build-package: building: @opam/ocamlfind@opam:1.8.1
# esy-build-package: pwd: /home/ducaale/.esy/3/b/opam__s__ocamlfind-opam__c__1.8.1-d095496f
# esy-build-package: running: 'bash' '-c' 'true'
# esy-build-package: running: './configure' '-bindir' '/home/ducaale/.esy/3__________________________________________________________________/s/opam__s__ocamlfind-opam__c__1.8.1-d095496f/bin' '-sitelib' '/home/ducaale/.esy/3__________________________________________________________________/s/opam__s__ocamlfind-opam__c__1.8.1-d095496f/lib' '-mandir' '/home/ducaale/.esy/3__________________________________________________________________/s/opam__s__ocamlfind-opam__c__1.8.1-d095496f/man' '-config' '/home/ducaale/.esy/3__________________________________________________________________/s/opam__s__ocamlfind-opam__c__1.8.1-d095496f/lib/findlib.conf' '-no-custom' '-no-topfind'
Welcome to findlib version 1.8.1
Configuring core...
Checking for #remove_directory...
Testing threading model...
systhread_supported: true
Testing DLLs...
Testing whether ppxopt can be supported...
Checking for ocamlc -opaque...
Configuring libraries...
native dynlink: found
labltk: not present
ocamlbuild: not present
camlp4: not present (normal since OCaml-4.02)
compiler-libs: found
dbm: not present (normal since OCaml-4.00)
num: not present (normal since OCaml-4.06)
bytes: found, installing fake library
spacetime: found
graphics: not found
Configuration for dynlink written to site-lib-src/dynlink/META
Configuration for str written to site-lib-src/str/META
Configuration for threads written to site-lib-src/threads/META
Configuration for unix written to site-lib-src/unix/META
Configuration for stdlib written to site-lib-src/stdlib/META
Configuration for bigarray written to site-lib-src/bigarray/META
Configuration for ocamldoc written to site-lib-src/ocamldoc/META
Configuration for compiler-libs written to site-lib-src/compiler-libs/META
Configuration for bytes written to site-lib-src/bytes/META
Configuration for raw_spacetime written to site-lib-src/raw_spacetime/META
Detecting compiler arguments: (extractor built) FAILED (see the file ocargs.log for details)
error: command failed: './configure' '-bindir' '/home/ducaale/.esy/3__________________________________________________________________/s/opam__s__ocamlfind-opam__c__1.8.1-d095496f/bin' '-sitelib' '/home/ducaale/.esy/3__________________________________________________________________/s/opam__s__ocamlfind-opam__c__1.8.1-d095496f/lib' '-mandir' '/home/ducaale/.esy/3__________________________________________________________________/s/opam__s__ocamlfind-opam__c__1.8.1-d095496f/man' '-config' '/home/ducaale/.esy/3__________________________________________________________________/s/opam__s__ocamlfind-opam__c__1.8.1-d095496f/lib/findlib.conf' '-no-custom' '-no-topfind' (exited with 1)
esy-build-package: exiting with errors above...
building @opam/ocamlfind@opam:1.8.1
esy: exiting due to errors above
It is often useful to maintain the state of a component when reparenting or changing the order of elements. Currently you cannot express the former and you express the latter by using keys. The use cases of reparenting are kind of an open question. Replacing key with constant identity stored in state could be an elegant and performant alternative solution for keys. The API would not be cumbersome with Hooks.
In the example below, foo
and bar
would be passed the same constant identity
let foo = (~contentId as id, children) = component(hooks => {
<view id />
});
let bar = (~contentId as id, children) = component(hooks => {
<view id />
});
let baz = () = component(hooks => {
let (id, hooks) = Hooks.componentId(hooks);
(hooks, Random.bool() ? <foo contentId=id /> : <bar contentId=id />)
});
An open question is what happens when you render two views with the same component identity at the same time (which is also problematic with keys but you expect keys to break in such cases)
From Discord:
it shoiuldn't wrap all children as list
it should only wrap children passed without ...
and then it should also work with <> </>
Tried to just bump the dependency myself, but seems like there are some changes to the library itself so some type-errors popped out.
I've noticed that the brisk-reconciler.ppx prevents using other JSX apis. I believe the ppx expects there to be a make
function but the standard JSX api built in to reason uses a createElement
function.
This comes up when trying to use something like Pastel.
print_endline(<Pastel color=Red> "Hello world" </Pastel>);
It also seems like the brisk reconciler ppx doesn't support list elements like normal Reason JSX:
<View> ...someListOfElements </View>
Is it possible to have the ppx translate let%component
to the standard createElement
form of JSX? Or is there some technical limitations around this?
There are two issues with nesting reconcilers now:
The solution here is to probably change the architecture so that there'll be one reconciler per app with a different mechanism to define output nodes. It should solve also other problems with naming etc.
Currently React goes belly up if we use duplicate keys. The possible arrangements are:
// From:
<Container> <Component key=key1 /> </Container>
// To:
<Container> <Component key=key1 /> <Component key=key1 /> </Container>
// From:
<Container> <Component key=key1 /> <Component key=key1 /> </Container>
// To:
<Container> <Component key=key1 /> </Container>
// From:
<Container> <Component key=key1 /> <Component key=key1 /> </Container>
// To:
<Container> <Component key=key1 /> <Component /> </Container>
// From:
<Container> <Component key=key1 /> <Component /> </Container>
// To:
<Container> <Component key=key1 /> <Component key=key1 /> </Container>
For now, the behaviour in all those cases is undefined.
A declarative, efficient, and flexible JavaScript library for building user interfaces.
๐ Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.
TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
An Open Source Machine Learning Framework for Everyone
The Web framework for perfectionists with deadlines.
A PHP framework for web artisans
Bring data to life with SVG, Canvas and HTML. ๐๐๐
JavaScript (JS) is a lightweight interpreted programming language with first-class functions.
Some thing interesting about web. New door for the world.
A server is a program made to process requests and deliver data to clients.
Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.
Some thing interesting about visualization, use data art
Some thing interesting about game, make everyone happy.
We are working to build community through open source technology. NB: members must have two-factor auth.
Open source projects and samples from Microsoft.
Google โค๏ธ Open Source for everyone.
Alibaba Open Source for everyone
Data-Driven Documents codes.
China tencent open source team.