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

observing the composed tree synchronously #3

Open
trusktr opened this issue Dec 22, 2023 · 7 comments
Open

observing the composed tree synchronously #3

trusktr opened this issue Dec 22, 2023 · 7 comments

Comments

@trusktr
Copy link

trusktr commented Dec 22, 2023

I think this might be a good foundation to start to observe the composed tree synchronously with. What I want to do is run logic synchronously when

  • an element is composed to its composed parent via normal connection (element is appended to parent, parent has no ShadowRoot, the normal parent is the composed parent)
  • an element is composed to a composed parent by becoming the child node of a ShadowRoot (element is appended to a ShadowRoot, the composed parent of the element is the ShadowRoot's host)
  • an element is composed to a composed parent by being slotted to a slot in a ShadowRoot of the element's normal parent, where the slot is a child of an element in the ShadowRoot (the composed parent is the parent node of the slot element)
  • an element is composed to a composed parent by being slotted to a slot in a ShadowRoot of the element's normal parent, where the slot is a child of the ShadowRoot directly (the composed parent is the host of the ShadowRoot)

and similar for all uncomposed reactions.

I have an initial version of this in my CompositionTracker class mixin. It has some limintations: currently it only works with a tree of elements that all extend from the same base class (i.e. it only detects composition with Lume elements, but not composition of a Lume element into any arbitrary non-Lume element because the implementation relies on custom element callbacks which built-in elements don't have, but that's where realdom comes in).

Also after trying a bunch of things, my code in that area has become a bit messy, and some details that should be in CompositionTracker are currently also here in SharedAPI. I need to clean it up and make CompositionTracker full generic, and I'd like for it to detect composition for a custom element regardless if the custom element is composed to a custom element or built-in element.

There's a problem with slotchange events: we do not get a set of mutation records, we can only detect the final nodes that are slotted, and for example we cannot run observations for nodes that are both removed and added within the same tick:

But I believe that with realdom we can synchronously track the set of possible mutations that would lead to a slotchange event being fired, and instead of relying on slotchange we can fire our own handlers at those moments.

With slotchange events, just as with the MutationObserver ordering problem,

there is also the chance that when a node is unslotted from one slot and slotted to another, the slotted reaction may fire before the unslotted reaction, leaving things in a broken state. Because of this, some edge cases in Lume are for sure broken (I haven't added tests for these cases yet, but when Lume goes mainstream, I really don't want anyone to run into such obscure problems).

@trusktr
Copy link
Author

trusktr commented Dec 22, 2023

There are some other issues I stumbled on of people wanting to observe the composed tree, but I can't find them right now. It will be nice to link them here.

@trusktr
Copy link
Author

trusktr commented Dec 22, 2023

I haven't added tests for these (slotchange) cases yet

I've added tests to Lume here locally now, and have verified they easily break expectations just as with MutationObserver. I'll will push this up soon.

@trusktr
Copy link
Author

trusktr commented Dec 23, 2023

My algo relies on features like assignedSlot in get composedParent() {} for finding a node's composed parent, which will simply break with closed ShadowRoots. So basically I will be forced to patch DOM APIs anyway and avoid relying on assignedSlot.

All Lume current elements have mode:open roots, but I can't guarantee that some user of Lume doesn't make a new element with mode:closed. EDIT: Hmm, well I suppose I can patch global attachShadow in Lume to force them to always be open. Not sure if this has any negative implications for people who want closed roots though.

@ox-harris
Copy link
Member

ox-harris commented Dec 23, 2023

There's indeed a great need for observing the composed tree. And the MutationObserver APi is obviously a far cry here.

My initial thoughts on a realdom-based solution are:

  • observe the Light DOM for additions and removals.
  • observe the Shadow DOM for additions and removals.
  • use heuristics (following the slotting algorithm) to statically determine the composition and fire corresponding events.
    F.E.: if <span slot=""a"><div> is added in light dom and shadow dom has a corresponding <slot name="a"></slot>, we can tell the composition and fire the relevant events.

Not sure how much of a good idea this is but will give things a try.

@trusktr
Copy link
Author

trusktr commented Dec 24, 2023

I think if we can make something reliable and easy to use, its a good idea!

I'd imagine thise would be built on top of realtime, and could possible be a separate module (import separately only if needed, to avoid globals that have a bunch of unused APIs).

I'm imagining there'd be something separate for use on any element from the outside:

class ComposedChildObserver {
  constructor(callback) {
    this.callback = callback
  }

  observe(element) {
    // implement with realtime()
  }
}

const observer = new ComposedChildObserver((changes) => {
  for (const change of changes) {
    for (const composed of change.composedChildren) console.log(composed)
    for (const uncomposed of change.uncomposedChildren) console.log(composed)
  }
})

observer.observe(someElement)

or similar. And maybe then also ComposedParentObserver that for the given node notifies when its composed parent changes.

And then a mixin like what I have in Lume could be impemented using those:

function CompositionTracker(Base) {
  return class extends Base {
    composedCallback(composedParent, compositionType) {/*...subclass implements...*/}
    uncomposedCallback(uncomposedParent, compositionType) {/*...subclass implements...*/}
    childComposedCallback(composedChild, compositionType) {/*...subclass implements...*/}
    childUncomposedCallback(uncomposedChild, compositionType) {/*...subclass implements...*/}

    // ... use realtime() as needed to call those methods if they are defined ...
  }
}

@trusktr
Copy link
Author

trusktr commented Dec 24, 2023

After we get that far, it would be interesting to implement reactive interfaces with Solid, f.e.:

const parent = createParentSignal(someElement)
const parent2 = createParentSignal(anotherElement)

createEffect(() => {
  // any time either parent changes, log:
  console.log(parent(), parent2())
})

And then after that :D perhaps an object API that creates the instantiates the underlying observation primitives only upon access (downside is it includes all code, so importing this API would have a bigger size):

const proxy = createElementProxy(someElement) // not sure about the `createElementProxy` name, but for sake of example

createEffect(() => {
  // any time the parent, children, or rootNode change, log them:
  console.log(proxy.parent, proxy.children, proxy.rootNode)
})


createEffect(() => {
  // log pointer states (bunching them up in a single effect is probably not what we want, but for sake of example):
  console.log(
    proxy.pointer.down.x, proxy.pointer.down.y,
    proxy.pointer.move.x, proxy.pointer.move.y,
    proxy.pointer.up.shiftKey)
})

trusktr added a commit to lume/lume that referenced this issue Dec 26, 2023
…early, before anything is

  loaded. See the [`<loading-icon>` docs and example](https://docs.lume.io/api/examples/LoadingIcon)!
- feat: add `composedCallback(composedParent, compositionType)` and
  `uncomposedCallback(uncomposedParent, compositionType)` methods that an element
  can implement to observe its _composed parent_ in the DOM _composed tree_.
  ```js
  class MyElement extends CompositionTracker(HTMLElement) {
    // as usual
    connectedCallback() {/*...*/}
    disconnectedCallback() {/*...*/}
    childComposedCallback(composedChild, compositionType) {/*...*/}
    childUncomposedCallback(uncomposedChild, compositionType) {/*...*/}

    // new methods
    composedCallback(composedParent, compositionType) {/*...*/}
    uncomposedCallback(uncomposedParent, compositionType) {/*...*/}
  }
  ```
- feat: add a new `.backgroundIntensity` property (`background-intensity`
  attribute) to the `Scene` class for controlling the scene background texture's
  intensity. `1` is full color and `0` is black.
  ```html
  <lume-scene background="cityscape.jpg" background-intensity="0.6" equirectagular-background></lume-scene>
  ```
- refactor: simplify and robustify camera handling by making `Scene`,
  `CameraRig`, `PerspectiveCamera`, `ScrollFling`, `PinchFling`, and
  `FlingRotation` classes more reactive in various spots and less imperative.
  Gotta love signals and effects!
- fix: when an element was disconnected and reconnected to DOM, a behavior might
  erroneously try to pull property values from its host element that could be
  `undefined`, passing them on to Three.js objects such as materials, which would
  cause meshes to silently render as invisible due to broken materials and without
  any errors in console. Now behaviors fall back to their own value for
  `undefined` values.
- fix: improve composition tracking so that child disconnected/connected events
  are not missed when a child is synchronously removed from and added to the same
  parent, otherwise state would get out of sync with state that is controlled by a
  child's connectedCallback and disconnectedCallback. Add manual tests in
  `/examples/tests/basic-shadow-dom.html`.
- fix: add a manual test cases in `/examples/tests/basic-shadow-dom.html` to
  catch the edge cases with slotchange events and composition reactions that
  prevented us from running `composedCallback` and `uncomposedCallback` in the
  correct order, and fix the edge cases. We will improve this code by subsequently
  migrating `CompositionTracker` to being implemented with Oxford Harrison's
  `realdom` library. webqit/realdom#3
- fix: use the same timing in `Css3dRendererThree` as with `WebGLRendererThree`
  on resize, so that the visual output of both render modes are not out of sync by
  one frame when the scene is resized.
- refactor: a scene's `webgl` and `enable-css` attributes no longer unload all
  Three.js objects in the whole tree when disabled, and now only the renderers
  used by the scene are cleaned up but all elements in the tree keep their objects
  intact. This makes everything a lot simpler. If you want to clean an element up,
  remove it from the DOM and drop your reference to it.
- refactor: remove `sizechange` observation from renderers, and instead have the
  Scene call back to the renderers via their `.updateResolution()` method when the
  scene's size changes.
- BREAKING: The `<lume-camera-rig>` element without `distance`, `min-distance`,
  or `max-distance` supplied now shows the same view as a scene's default camera
  when no camera is explicitly used in the tree, and the rig automatically adjusts
  itself in order to show the same view as the default camera will based on a
  scene's `perspective` value, matching the behavior of CSS `perspective`. When a
  scene's `perspective` changes, the camera rig's `distance` and the camera's
  `fov` will adjust to match CSS perspective behavior, and the rig's
  `min-distance` and `max-distance` properties will auto-adjust to be 1/2 and 2
  times the `distance` respectively so that the default rotating view feels
  roughly the same no matter what `perspective` is applied. If your initial camera
  view is off when you're using `<lume-camera-rig>`, migrate by providing initial
  values for `distance`, `min-distance`, and `max-distance`.
- BREAKING: if you are using `observeChildren`, the `skipTextNodes` option has
  been renamed to `includeTextNodes` and text nodes and comments nodes are now
  ignored by default so you will need to set this to `true` to get back the
  previous default behavior, or delete the `skipTextNodes` option to keep skipping
  text and comment nodes. By default `observeChildren` will now call `onConnect`
  and `onDisconnect` for all mutaion records, not only the final result.
  Previously, disconnecting and reconnecting a node from and to the same parent
  would result in a net zero change and callbacks would not be called, but now
  they will be called. Set the new `weighted` option to `true` to get back the
  previous behavior.
- BREAKING: remove `GL_LOAD`/`GL_UNLOAD`/`CSS_LOAD`/`CSS_UNLOAD` events. When an
  element is connected, GL content is created, no need for the events, and
  toggling a scene's `webgl` or `enable-css` attributes no longer destroys all
  rendering objects in the tree, only enables or disables the renderers that the
  scene uses. To migrate, run your logic when elements are connected, and clean
  up when they are disconnected.
  Before:
  ```js
  const el = document.createElement('lume-mesh')
  el.on('GL_LOAD', () => {
    console.log(el.three.material.color)
  })
  // ...later
  scene.append(el)
  ```
  After:
  ```js
  const el = document.createElement('lume-mesh')
  // ...later
  scene.append(el)
  console.log(el.three.material.color)
  ```
- BREAKING: removed `_loadGL`, `_unloadGL`, `_loadCSS`, `_unloadCSS` methods in
  element classes (and same for the non-`_`-prefixed methods of the same name in
  behavior classes) and moved logic to `connectedCallback` and
  `disconnectedCallback` methods (in both elements and behaviors) to make things
  simpler. We also removed `createGLEffect` and `createCSSEffect` and replaced
  usages with `createEffect`. To migrate, use `connectedCallback` and
  `disconnectedCallback` for creation/cleanup, and create effects with
  `createEffect`.
  Before:
  ```js
  import {Mesh, element, attribute} from 'lume'

  @element('my-mesh')
  class MyMesh extends Mesh {
    @Attribute foo = "bar"

    _loadGL() {
      if (!super._loadGL()) return

      this.createGLEffect(() => console.log(this.foo))

      return true
    }
  }
  ```
  After:
  ```js
  import {Mesh, element, attribute} from 'lume'

  @element('my-mesh')
  class MyMesh extends Mesh {
    @Attribute foo = "bar"

    connectedCallback() {
      super.connectedCallback()

      this.createEffect(() => console.log(this.foo))
    }
  }
  ```
  And similarly with `*CSS*` variants.
- BREAKING: Along with the previous point, we removed the `glLoaded` and
  `cssLoaded` reactive properties from both elements and behaviors. If you
  depended on those, run your logic after an element is connected into the DOM,
  cleanup after it is removed (rather than using an effect and returning early
  when `glLoaded` or `cssLoaded` is false).
  Before:
  ```js
  const el = document.createElement('lume-mesh')
  createEffect(() => {
    if (!el.glLoaded) return
    console.log(el.three.material.color)
  })
  // ...later
  scene.append(el)
  ```
  After:
  ```js
  const el = document.createElement('lume-mesh')
  // ...later
  scene.append(el)
  console.log(el.three.material.color)
  ```
- refactor: Remove some `disconnectedCallback`s and instead use `onCleanup` in
  effects created in `connectedCallback`s.
ox-harris added a commit that referenced this issue Dec 30, 2023
…utations across shadow roots; see issue #3. Experimental API: realtime().track() for tracking connectedness; much like connected/disconnectedCallback; see issue #4.
@trusktr
Copy link
Author

trusktr commented May 15, 2024

^ See the whatwg/dom issue I linked.

I've added tests to Lume here locally now, and have verified they easily break expectations just as with MutationObserver. I'll will push this up soon.

I've added more manually-operated tests here. I see you implemented the cross-root feature. Looking forward to checking that out. So it looks like you're getting closer to this:

  • use heuristics (following the slotting algorithm) to statically determine the composition and fire corresponding events.
    F.E.: if <span slot=""a"><div> is added in light dom and shadow dom has a corresponding <slot name="a"></slot>, we can tell the composition and fire the relevant events.

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

No branches or pull requests

2 participants