Skip to content

Splitting and joining onchange/set $bindables/$state for interop with vanilla libraries and prevent circular issues #16955

@petermakeswebsites

Description

@petermakeswebsites

Describe the problem

I have a pain point with Svelte which is minor but still ther. Despite Svelte's fantastic ability to work with vanilla libraries, the Svelte reactivity system requires a bit of tinkering in certain cases. Because of the built-in two-way magic reactive nature, it's easy to get into circular dependency issues when you're transforming data to and fro. It can be solved quite easily I think.

Here's the issue...

Example, take TipTap. TipTap is a rich text editor that has a callback function for updates as well as the ability to set the JSON.

So imagine this pseudocode, abridged for conceptual purposes:

<script>
  let {json = bindable()} = $props()
  let tiptap = new Tiptap({div: ...)
  $effect.pre(() => {
       // Listen for changes from parent and apply them to TipTap
       tiptap.setJSON(json)
  })
  
  //  Listen for input changes from user and propagate to parent
  tiptap.oncontentchange((newJson) => json = newJson)
</script>
<div bind:this... tip tap etc />

Then in the parent we have simply <TIpTap bind:json={json}>

In this situation, this works fine. But the reason it works is because of the equality check of the input and output...

  1. User inputs
  2. oncontentchange called, json (bindable rune) set to new json
  3. parent and child runs effects from new change
  4. effect propagates to the $effect.pre above
  5. tip tap sets the "new" json (actually the same as the one it already has)
  6. unless tip tap does equality check (not all libs do) oncontentchange runs again, resetting json to newJson (again, actually the same)
  7. reactivity system detects via deep equality that there is no change, and so no effects are run. The end.

Not efficient but the code is relatively clean and it works...

But, things get more finicky when you want transformations. For example, lets say when TipTap has no text, you want the the parent to be set to "null", and likewise, when the parent sets json to "null", you want tiptap to put its empty json.

<script>
  const EMPTY_JSON_FOR_TIPTAP = 
  let {json = bindable()} = $props()
  let tiptap = new Tiptap({div: ...)
  $effect.pre(() => {
       // Listen for changes from parent and apply them to TipTap
       tiptap.setJSON(json !== null ? json : {doc: {}})
  })
  
  //  Listen for input changes from user and propagate to parent
  tiptap.oncontentchange((newJson, isEmptyAfterTrim) => json = isEmptyAfterTrim(newJson) null : newJson)
</script>
<div bind:this... tip tap etc />

Maybe you can also split a state into a derived and a setter, in which case the derived will only change when the input comes from the inside

But now see we have an issue. When user inputs a space in the text, oncontentchange detects that the editor has no characters, and sets json to null. This works as we want for the parent, but then the effect.pre runs again in this component, setting tiptap again, except it sets tiptap to an empty new one, removing the space.

The reason for this is fundamentally because this component really has no way of knowing whether the change of json came from itself or from the parent. And that's really the crux of the matter.

I've run into some variation of this issue many times and my workarounds have never been as pretty as I wanted.

Describe the proposed solution

We want a one-way system to know what comes from the parent and what comes from the component. The easiest way to do this would be to be able to "split" a bindable (or any state for that matter) into onchange (this can also be a $derived) and a setter.

I propose something like

const { onchange, set } = $split(someRune)

The idea with this is that this particular duo of onchange and the setter is that onchange doesn't run if the setter is SET, only if the rune is set on the OTHER SIDE of the splitter. (in the case below, if the parent sets json)...

Example:

<script>
  let {json = bindable()} = $props()

  let tiptap = new Tiptap({div: ...)

  // the change callback (2nd arg) will only run if the change comes from json itself (in this case, the parent), and will be ignored if it comes from "set"
  const set = $split(json, newJsonFromParent => tiptap.setJSON(newJsonFromParent !== null ? newJsonFromParent : EMPTY_JSON_FOR_TIPTAP))

  
  //  Listen for input changes from user and propagate to parent
  tiptap.oncontentchange((newJson, isEmptyAfterTrim) => set(isEmptyAfterTrim(newJson) null : newJson))
</script>
<div bind:this... tip tap etc />

And here are the steps now

  1. User inputs
  2. oncontentchange called, json (bindable rune) set to new json
  3. only the parent's reactions are triggered, and the $split's "onchange" doesn't fire (because it was set by the local set, not from the parent).
  4. The end.

As a side note, it would also be cool to have a "combine" version instead, doing the exact opposite.

Importance

nice to have

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions