Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: solid-primitives-parser #276

Merged
merged 29 commits into from
Jan 27, 2023
Merged

feat: solid-primitives-parser #276

merged 29 commits into from
Jan 27, 2023

Conversation

bigmistqke
Copy link
Contributor

@bigmistqke bigmistqke commented Dec 31, 2022

A primitive to tokenize your solid-components to enable custom parsing/custom renderers.

It exists out of two parts, tokenize() and childrenTokens():

  • const token = tokenize(callback, meta)
    • takes two parameters:
      • a callback-function that takes in props just like a regular Solid-component
      • an object that can contain any additional metadata
    • returns a function token
      • this function spoofs to be a JSX.Element so it can be used in JSX without type-errors
      • its props are Object.assigned() to this spoof-function, so they can be accessed from their parent
      • a symbol $TOKEN for validation
  • const tokens = childrenTokens(() => props.children)
    • takes as parameter:
      • its children in a callback, similar to children()-api
    • returns all valid tokens as a memo
      • they are filtered by checking for the $TOKEN-symbol

I made a very simple example in the repo with a calculator, and played around with a threejs-renderer

I originally started playing with these type of hacks for this cms-experiment and @thetarnav mentioned that they also used something similar for solid-aria so it would be sweet to have this standardized. I think I will try to make a simplified version of the cms since it is quite a different usecase then the threejs-parser (callback() does return JSX which is being rendered, and data is inject from the parser).

The Object.assign()-tip came from someone in the typescript-discord, either liquid or foolswisdom. The remark they gave back then was that it's impossible to type it in a way to disallow these spoofed JSX-elements to be used outside the Parser, this can only be done in runtime.

talking points:

  • Right now the token.callback does not take any parameters, but instead the props of the component are being passed into the callback. you can inject or manipulate data with Dynamic in the case of when you would use the callback to render, and I think you might even be able to set them directly on the token-function with another Object.assign, but haven't tested this. Another option possible would be to have the callback-function inside token to take besides the component-props also an object coming from the parent-parser: so something like tokenize((props: {...}, parserParams: {...}) => {...}).
  • maybe instead of having tokenize pass in the component-props when you call callback() it should be done explicitly callback(token.props, {... parserParams ...}) to demistify the inner-workings.
  • maybe instead of the props being inferred from the callback-function it could be done by a generic: tokenize<Props>(() => {...}, {}). that way the callback is completely decoupled from its props, which feels a bit less spoofy and then you could type callback independently from the component's props. I imagine that in most of the usecase you do want those props though, so it will be a lot of token.callback(token.props), but that's maybe better then doing it automagically.
  • mix and matching multiple different parsers could get buggy since they are checking for the same symbol.

TODOS:

  • the whole type-story can use some assistance, I am still a ts-noob
  • tests
  • docs

Also very open for API-suggestions.
I think this can become a very powerful tool in the solid toolchain, if done correctly. Since there is some spoofing going on to trick typescript to accept the JSX, the API-choices feel extra important.

@changeset-bot
Copy link

changeset-bot bot commented Dec 31, 2022

⚠️ No Changeset found

Latest commit: 508826c

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@bigmistqke bigmistqke marked this pull request as draft December 31, 2022 17:03
packages/parser/package.json Outdated Show resolved Hide resolved
instead of having a global $TOKEN, $TOKEN is now scoped to createParser
this way multiple parsers can co-exists.

token.meta is now spread out over token. this way keys passed in tokenize()
can be used to typeguard so we have narrower types while parsing
@bigmistqke
Copy link
Contributor Author

bigmistqke commented Jan 3, 2023

I continued my experimentations and added a cms-implementation as mentioned above: it can be found here

A schema written in JSX generates a cms to enter data and typescript-types describing the schema, idea inspired by payload. Nested arrays are supported. These types could be consumed hypothetically by a front-end who would fetch from the cms and then you have a cms with typesafety!

schema-to-ts.mp4

A couple of design decisions:

  1. I decided to remove the first callback-parameter from tokenize and only remain the second parameter, the object I called meta (naming suggestions welcome). This way tokenize does not prescribe to a specific way it should be used and it's up to the parsing to create the logic: Meta could have properties that return JSX, or it could have callbacks that do something with the componentProps and the parserParameters we pass into it, but it does not have to.

so

tokenize((props: Props, parserParams: ParserParams) =>  ..., { ...meta... })

becomes

tokenize({ ...meta... })

instead of inferring the types from the callback-parameters they are now typed in a generic. It takes a type of type Token that looks like Meta & {props: Props}. Originally I wanted to keep meta and props in two different keys, like {meta: Meta, props: Props}, but this turned out to make typeguarding impossible

to illustrate

type Green = Token<{id: 'green-id'}, {color: 'green'}> 
// {meta: {id: 'green-id'}, props: {color: 'green'}};
type Red = Token<{id: 'red-id'}, {color: 'red'}> 
// {meta: {id: 'red-id'}, props: {color: 'red'}};
type Color =  Green | Red;

const x: Color;
if(x.meta.id === 'green-id')
   x.props.color // Green | Red

while now

type Green = Token<{id: 'green-id'}, {color: 'green'}> 
  // {id: 'green-id', props: {color: 'green'}};
type Red = Token<{id: 'green-id'}, {color: 'green'}> 
  // {id: 'red-id', props: {color: 'red'}};
type Color =  Green | Red;

const x: Color;
if(x.id === 'green-id')
   x.props.color // Green

there is still some friction with types, for example:

type PropsGreen = {color: 'green'}
type Green = Token<PropsGreen, {callback: (props: PropsGreen) => void}> 
  // {callback: (props: PropsGreen) => void, props: {color: 'green'}}
type PropsRed = {color: 'red'}
type Red = Token<PropsRed, {callback: (props: PropsRed) => void}> 
  // {callback: (props: PropsRed) => void, props: {color: 'red'}}
type Color =  Green | Red;

const x: Color;
x.callback(x.props) 
  // typerror x.props: PropsGreen | PropsRed is not assignable to parameter of type PropsGreen & PropsRed 

I would have preferred to keep meta and props seperated, but this way we get a lot more type-information during parsing and there are far less typecasts needed.

  1. I decided to scope $TOKEN, the symbol with which we identify a valid token, behind a createParser-closure. createParser returns const {childrenTokens, tokenize}: childrenTokens will only return children which are created by that specific tokenize. These could be exported by library-maintainers to extent the parser.

I updated this repo accordingly.

@bigmistqke
Copy link
Contributor Author

bigmistqke commented Jan 4, 2023

API-idea

type CustomToken = {id: 'token', props: {type: string}}
const token = tokenize<CustomToken>({id: 'token'});
token // {id: 'token', props: {type: string}}

becomes

type CustomToken = {id: 'token', props: {type: string}}
const token = tokenize<CustomToken>((props) => ({id: 'token', props}));
token // {id: 'token', props: {type: string}}

so customer-props are being added to the token becomes an explicit action done by the dev, instead of something we do behind the scenes.

Copy link
Member

@thetarnav thetarnav left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good.
Here are a couple of things that I caught
Need to look through this one more time later

packages/parser/README.md Outdated Show resolved Hide resolved
packages/parser/src/index.tsx Outdated Show resolved Hide resolved
packages/parser/src/index.tsx Outdated Show resolved Hide resolved
packages/parser/src/index.tsx Outdated Show resolved Hide resolved
packages/parser/src/index.tsx Outdated Show resolved Hide resolved
packages/parser/src/index.tsx Outdated Show resolved Hide resolved
replace the meta-object with a callback through which you pass the
component-properties. remove Token-type helper to not prescribe to a way
the tokens should be typed
@bigmistqke
Copy link
Contributor Author

bigmistqke commented Jan 4, 2023

there is still some friction with types, for example:

type PropsGreen = {color: 'green'}
type Green = Token<PropsGreen, {callback: (props: PropsGreen) => void}> 
  // {callback: (props: PropsGreen) => void, props: {color: 'green'}}
type PropsRed = {color: 'red'}
type Red = Token<PropsRed, {callback: (props: PropsRed) => void}> 
  // {callback: (props: PropsRed) => void, props: {color: 'red'}}
type Color =  Green | Red;

const x: Color;
x.callback(x.props) 
  // typerror x.props: PropsGreen | PropsRed is not assignable to parameter of type PropsGreen & PropsRed 

this seems to be a known and unresolved limitation of typescript and will probably stay so so I suppose out of the scope of this pr

TProps extends { [key: string]: any },
TToken extends { [key: string]: any } & { id: string }
>(
function createToken<TProps extends { [key: string]: any }, TToken extends TTokens>(
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there a way you can make TToken fit the union TTokens without extends?

type GreenToken = {id: 'green'}

const {createToken, childrenTokens} = createJSXParser<GreenToken>('color-parser')

const Green = createToken<Props>(props => ({id: 'green', extra: 'key'}) 
  // no type-error, since {props, id: 'green', extra: 'key'} extends {id: 'green'}

@bigmistqke
Copy link
Contributor Author

bigmistqke commented Jan 4, 2023

Until now I have only been testing with static schemas, except for For-loops in the CMS-example.
I was trying now to make Show work as well, but I am having some issues.

Show returns a signal, to resolve these I added

const resolveChild = (child: any) => {
  while (true) {
    if (typeof child !== 'function') return child
    if ($TOKEN in child) return child
    child = child()
  }
}

And added a memo to the callback of childrenTokens, like children does too (otherwise it would instantiate all the children everytime children would change).

But now when implementing it in the ThreeParser, we get the following bug:

bug.mp4

once a new token is shown, we lose reactivity to the props of the original token.

I thought first the props must have been resolved, but when I inspect the props of the token inside the console, I do get { rotation: Getter, children: Getter }. When I log props.rotation the value does update appropriately. Even when I log the props inside a setInterval inside of token.callback, I get updated values of props.rotation, but somehow these are not triggering effects anymore.

bug.mp4

link to the codesandbox

(code that implements adding and removing of children is in ThreeParser line 53: childrenEffect)

[UPDATE]

I was able to fix the bug by wrapping the effects in ThreeParser in a createRoot!
I guess this is something we should communicate in docs? Or could we maybe let it it share the root of the original jsx? Is this root-management something we should leave to the responsibility of the parser-creator? I am still a bit fuzzy on what a root exactly is and what the implications are, so open to suggestions.

bug.mp4

Show is now working!

[UPDATE]

For.mp4

For too

@bigmistqke
Copy link
Contributor Author

bigmistqke commented Jan 5, 2023

I just realized you can also access the token-properties immediately from any callbacks by simply accessing this

createToken((props: Props) => ({
  props,
  id: 'token',
  callback: function(){
     console.log(this.id);      // 'token'
     console.log(this.props);   // Props
  }
}))

that's pretty clean.
in 99.9999% that will be enough, so you don't have to type all of the callback-parameters.

@bigmistqke bigmistqke marked this pull request as ready for review January 6, 2023 00:02
@bigmistqke bigmistqke marked this pull request as draft January 6, 2023 00:02
@bigmistqke
Copy link
Contributor Author

bigmistqke commented Jan 6, 2023

played around with another parser: a poor man's flutter

Screen.Recording.2023-01-05.at.23.50.38.mov

jsx to drive a canvas-layout, with konva as canvas-paint-library and yoga as flexbox-layout-engine.
code link

another pattern I discovered was to use the callback to initialize some values:

export const Flex = createToken<PropsFlex, TokenFlex>((props) => {
  const tokens = childrenTokens(() => props.children)
  const node = Yoga.Node.create()
  node.setFlexDirection(props.style.flexDirection)
  return {
    props,
    node,
    id: 'Flex',
  }
}

very handy.
I think the API is now pretty much finalized as far as I am concerned, I am pretty happy with it.
the createRoot everywhere is a bit awkward, but besides that it's pretty lean and clear.

will start to write docs.
I am a test-noob, so maybe if someone else could look into this, or could give me some assistance.

@bigmistqke
Copy link
Contributor Author

bigmistqke commented Jan 6, 2023

I have been looking deeper into why createEffects stop running once another token is shown.
I first thought it was because of losing reference to its root, but that was not the case.
I went a route as well where I put the parser in one root and distributed them with the tokens and did runWithOwner(owner, () => ...the callback code...), but to no avail.

The callback-function of the first Mesh is being disposed once another Mesh is shown, it runs when I do onCleanup inside. I don't really understand why.

I added a cache-mechanism so that the token is only initialized once. This stopped the disposal of the jsx-component, but the callback still loses reactivity and is being disposed once another token is shown. (this code got removed)

[EDIT]

so initially thought that wrapping the callback in a createMemo could help too, as it preserved reactivity in my test-case, but then it lost reactivity again when I tested a bit further.

It does however prevent those cleanups that should not be happening: with the callback wrapped in a createMemo, the disposal of the callback is synchronous to the one of the related jsx-component, without it it would dispose once another token was shown.

so yes, this part still needs a bit of work.

[EDIT2]

you also lose the typing of this inside a createMemo

[EDIT3]

if you wrap the whole callback in a createRoot the callback also has the lifecycle of the jsx-component, and you keep the typings to this.

sadly it does not seem to work to wrap the values returned by the createToken-callback in a createRoot inside createToken or childrenTokens ¯\(ツ)

[EDIT4]

ok so i found the reason of the bug:

the adding and removing of the meshes to the scene was being done inside an effect:

const elements = new Map();
createEffect(() => {
    const newElements = new Map()
    tokens().forEach((token) => {
      if (token.type === 'Object3D' || token.type === 'Light') {
        if (elements.has(token)) {
          newElements.set(token, elements.get(token))
        } else {
          untrack(() => newElements.set(token, token.callback(token.props as any)))
        }
      }
    })
    newElements.forEach((value, key) => {
      if (!elements.has(key)) {
        untrack(() => object.add(value))
        elements.set(key, value)
      }
    })
    elements.forEach((value, key) => {
      if (!newElements.has(key)) {
        untrack(() => object.remove(value))
        elements.delete(key)
      }
    })
  })

the Owner of callback was coming from this createEffect so that when the createEffect ran again, it got disposed.

the solution I found was to do something like:

const createRootlessEffect = (callback: (prev: unknown) => void) => {
  const owner = getOwner()
  createEffect((prev) =>
    owner ? runWithOwner(owner!, () => callback(prev)) : callback(prev)
  )
}

and use this instead of createEffect in the above code (maybe an addition for solid-primitives/rootless?)
This way the callback inside the effect runs with the Owner outside the effect. this removes the need for any createMemo or createRoot.

I guess the bug makes a lot of sense if you understand how those Owners actually work (I still don't honestly). Maybe we should just document this behavior well, because adding and removing children seems to be something that will happen a lot.

[EDIT5]

function mapTokens<T>(tokens: Accessor<T[]>, callback: (token: T) => any) {
  const cache = new Map<T, () => void>()

  createRootlessEffect(() => {
    const current = new Set()
    tokens().forEach((token) => {
      if (!cache.has(token)) {
        createRoot((dispose) => {
          callback(token)
          cache.set(token, dispose)
        })
      }
      current.add(token)
    })

    cache.forEach((dispose, token) => {
      if (!current.has(token)) {
        cache.delete(token)
        dispose()
      }
    })
  })
}

is a nice abstraction that could help a lot,
then for adding/removing children you can do something like

const childrenEffect = (
  object: THREE.Object3D,
  tokens: Accessor<Token[]>
) => {
  mapTokens(tokens, (token) => {
    const element = token.callback(token.props as any)
    object.add(element)
    onCleanup(() => object.remove(element))
  })
}

very clean!

I don't know if it should be a part of createJSXParser since it could be used for mapping any accessor-array (i sorta expected mapArray to work like this but i don't get how mapArray works tbh)

@bigmistqke bigmistqke marked this pull request as ready for review January 23, 2023 16:36
@thetarnav
Copy link
Member

This is looking good for me.
@bigmistqke could you look through my changes to see if I didn't mess something up?

@bigmistqke
Copy link
Contributor Author

yes, looks great, @thetarnav !
very nice!

@thetarnav thetarnav merged commit abbe054 into solidjs-community:main Jan 27, 2023
@bigmistqke
Copy link
Contributor Author

@thetarnav
on second thought, maybe we should return TokenComponent<Token> instead of JSX.Element in createToken?

const jsxToken = <SomeToken staticProp="text" dynamicProp={signal()}/>
jsxToken // is always gonna be typed JSX.Element, is a jsx-thing i suppose
jsx.props // type-error
(jsx as any as SomeToken).props // no type-error, but ye

const token = SomeToken({staticProp: "text", get dynamicProp(){return signal()}})
token // TokenComponent<SomeToken>
token.props // no type-error

const Component = () => <Parser>{token}</Parser> // no type-error here either

sometimes it's handy to use a token outside jsx, writing it like a callback might make sense there.

@thetarnav
Copy link
Member

yeah I don't mind, we could do that

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants