Skip to content

Fix TypeScript errors, get test suite running, and fix scene graph ordering [next] #47

Merged
bigmistqke merged 8 commits into
solidjs-community:nextfrom
bigmistqke:next
Apr 28, 2026
Merged

Fix TypeScript errors, get test suite running, and fix scene graph ordering [next] #47
bigmistqke merged 8 commits into
solidjs-community:nextfrom
bigmistqke:next

Conversation

@bigmistqke
Copy link
Copy Markdown
Contributor

@bigmistqke bigmistqke commented Apr 28, 2026

This PR does three things:

  • fixes all TypeScript errors across the codebase,
  • gets the test suite running after an incomplete migration from Vite to tsup left several things unported, and
  • fixes two correctness issues:
    • a bubbling bug in the default event registry and
    • incorrect scene graph ordering when reactive lists reorder their children.

TypeScript

  • src/utils.ts: Fixed defaultProps return type to correctly reflect that defaults fill in optional keys. Fixed resolve() overload to cast the function case to Accessor<T> to avoid a union ambiguity. Added overloads to load() to properly distinguish single-URL vs record inputs. Fixed LoadOutput to handle readonly array inputs.
  • src/hooks.ts: Fixed useLoader to use the normalized _input instead of raw input when writing to the registry, preventing cache misses. Fixed return types to PromiseMaybe where appropriate.
  • src/create-events.ts: Fixed empty object default for createThreeEvent config parameter ({} as TConfig).
  • src/components.tsx: Fixed Resource's <Show fallback> to cast to JSX.Element.
  • tests/core/renderer.test.tsx: Fixed attach callback types.

Test suite infrastructure

The project was migrated from Vite to tsup for building, but the test suite was never updated to match. Three things were left unported:

  • vitest.config.ts (new): Without this file vitest ran with defaults, which caused vite-plugin-solid to inject solid-refresh HMR code into every module — triggering a MagicString double-edit error in Vite 5's SSR transform pipeline. Fixed by adding an explicit config with solidPlugin({ hot: false }) to disable HMR injection in the test environment, plus jsdom as the test environment with a setup file.
  • tests/setup.ts (new): Provides a ResizeObserver mock for jsdom (deferred via queueMicrotask to match real browser behaviour — firing synchronously triggers a "signal written in owned scope" warning). Also patches console.warn to print a stack trace on that warning.
  • src/data-structure/stack.ts: Replaced import.meta.env?.MODE with process.env.NODE_ENV. import.meta.env is Vite-specific and passes through untransformed when built with tsup/esbuild.

Test fixes

  • src/testing/index.tsx: The test utility was passing camera: but createThree expects defaultCamera:, so the camera stayed at the origin and all raycasts missed. Also overrode canvas.getBoundingClientRect to return real dimensions — jsdom always returns zeros, causing the raycaster to compute pointer = (Infinity, Infinity).
  • src/utils/use-measure.ts: setElement now calls forceRefresh() synchronously after setting the element. Previously bounds were only updated via ResizeObserver, which fires asynchronously (queueMicrotask), so bounds were still zero when the first event fired in tests.
  • src/create-events.ts: Fixed a bubbling bug in createDefaultEventRegistry — the while (node = node.parent) walk called getMeta(intersection.object) on every iteration instead of getMeta(node), so the handler was invoked once per ancestor rather than once per intersected object.
  • tests/core/events.test.tsx: onPointerEnter/onPointerLeave are non-stoppable (solid-three follows DOM semantics, not r3f), so removed the incorrect stopPropagation assertion. Added beforeEach to reset shared mocks in the pointer capture describe block to prevent cross-test contamination. Marked pointer capture tests as .todo since the feature is not yet implemented.
  • tests/core/renderer.test.tsx: Removed outputEncoding/texture.encoding assertions — these APIs were removed in Three.js r152, only outputColorSpace/colorSpace remain. Updated snapshot to r164.

Entity component

Entity was using whenMemo (= createMemo(when(...))) which re-creates its reactive scope whenever from changes. Since useProps was called inside that scope, children(() => props.children) was evaluated fresh on each from change, causing JSX children to be reconstructed as new Three.js instances with new UUIDs.

Fixed by calling useProps once at component level and passing an accessor — the reactive scope for children is now owned by the component root and persists for its lifetime:

// before
const memo = whenMemo(() => config.from, from => {
  const instance = meta(...)
  useProps(instance, rest)  // children() re-created every time `from` changes
  return instance
})

// after
const instance = createMemo(() => config.from ? meta(...) : undefined)
useProps(instance, rest)  // children() created once at component level

Scene graph ordering

Previously useSceneGraph used mapArray for everything, which is keyed by item identity anddoes not respond to reordering. When a reactive list (e.g. <For>) reordered its items, Object3D.children stayed in the original order.

We looked at prior art before settling on an approach:

  • R3F gets explicit insertBefore(parent, child, anchor) calls from the React reconciler and directly splices Object3D.children — full absolute ordering including external children.
  • Threlte calls parent.add(child) / parent.remove(child) and relies on implicit remounts to reorder — simple but doesn't handle reordering without teardown.
  • TresJS maintains a parallel __tres.objects tracking array with anchor-based insertion, but still calls parent.add() for actual attachment — the two arrays can diverge.

We follow R3F's approach, adapted for Solid's reactivity model (no reconciler, so no insertBefore
protocol):

  • mapArray still handles per-item lifecycle: solid-three metadata (parentMeta.children, childMeta.parent), attach props, and event listener registration — with proper onCleanup teardown per item.
  • A new createComputed(prev =>) handles all Object3D add/remove/reorder. It receives the previous managed Set as its argument, diffs against the new desired array to remove stale children, inserts new children before their next already-present sibling (dispatching added/childadded events as Three.js would), and finally does a position-assignment pass to reorder: it collects the indices in parent.children currently occupied by managed children, then writes the desired order into those same slots. This preserves external (non-managed) children at their positions while maintaining correct relative order among managed children.

Test plan

pnpm test — 41 tests pass, 2 .todo (pointer capture not yet implemented).

- Fix vitest SSR transform conflict (solidPlugin hot:false, process.env.NODE_ENV)
- Fix camera position in test utility (defaultCamera, getBoundingClientRect)
- Fix forceRefresh called synchronously in useMeasure setElement
- Fix event bubbling bug in createDefaultEventRegistry (node vs intersection.object)
- Fix Entity to use createMemo instead of whenMemo, preserving children across from-prop changes
- Rewrite useSceneGraph to handle add/remove/reorder with R3F-style ordering:
  mapArray handles per-item metadata/attach/events; a createComputed(prev=>) loop
  manages Object3D children with correct insertion order and external-child preservation
- Update tests: remove legacy Three.js r152 encoding assertions, mark unimplemented
  pointer capture tests as todo, fix non-stoppable onPointerEnter assertion
… loop

- applySceneGraph no longer calls parent.add/remove for Object3D children
- mapArray handles per-item lifecycle (metadata, attach, events)
- createComputed(prev =>) loop manages Object3D add/remove/reorder:
  uses child.parent === parent for O(1) new-child detection, diffs prev
  managed set for removal, and syncs order via a dual-cursor walk over
  parent.children and childArray avoiding a slots[] allocation
- Entity: replace whenMemo with createMemo + top-level useProps so
  children() is only resolved once across from-prop changes
nodeSet.values().toArray() requires Node.js 22+; CI runs Node.js 20.
Array.from(nodeSet) is equivalent and universally supported.

Also removes leftover debug console.log from Resource component.
Set.prototype.difference() requires Node.js 22+; CI runs Node.js 20.
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Apr 28, 2026

Open in StackBlitz

npm i https://pkg.pr.new/solid-three@47

commit: e8312ac

@bigmistqke bigmistqke merged commit 5449e5a into solidjs-community:next Apr 28, 2026
2 checks passed
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.

1 participant