ADR-002: Foldkit adapter seam is CustomElement properties and events plus a one-shot Mount
- Status: Accepted and implemented (2026-10-14, Phase 3). Time-travel pause is verified in a browser through the Mount’s contract (
e2e/visual.test.ts,pause.htmldrivesviewStateChangestoPausedand back); the DevTools overlay itself, which only Foldkit’s Vite plugin injects, is not exercised. - Evidence: d1-adapter spike (removed after Phase 1; kept in commit
02fa947), spike results
Context
The adapter must (a) update existing scene objects when the model changes, (b) turn pointer interaction into typed Foldkit Messages, (c) release every owned resource and listener on unmount, and (d) keep Foldkit’s update pure.
Foldkit constraints, verified in foldkit@0.168.0:
- Mount args are captured once at mount and never refreshed.
Propis not part of the publicfoldkit/htmlsurface.CustomElement.defineexposes typed property factories that are diffed per render, and event factories that Schema-decodeCustomEvent.detail.Mount.definekeeps its scope open for the element’s lifetime.Mount.defineStreamcloses its scope when the stream completes.- A Mount’s
executehasR = never.
Options considered
- A. CustomElement whose element class owns its runtime. Works, but the lifecycle sits outside Foldkit’s runtime: a second runtime, invisible to DevTools, with HMR re-registration hazards.
- B. Mount + a raw
Prop. Relies on non-public API. - C. A Command plus a
ManagedResourcethat pushes scene updates fromupdate. The scene stops being a function of the view, and nothing is declarative. - Hybrid (chosen).
CustomElement.definesupplies the typedsceneproperty and typed events.Mount.defineon the same element acquires the scene runtime in Foldkit’s fiber and releases it on unmount.
Decision
// Shape of the public adapter (names provisional)const scene = EffectThreeFoldkit.scene.withMessage(h);scene( [ scene.Scene(description), // diffed property; pure data scene.OnScenePointer((d) => ClickedNode({ nodeKey: d.nodeKey })), // Schema-decoded, typed h.OnMount(EffectThreeFoldkit.lifecycle(services)), // scope = element lifetime ], [],);- The Mount installs a property interceptor that feeds a bounded “latest scene” slot. The runtime dedupes structurally equal descriptions, because Foldkit re-sets the property with a fresh object on every render.
- Pointer events: the DOM listener → normalised device coordinates (NDC) → core raycast →
element.dispatchEvent(new CustomEvent("scene-pointer", { detail }))→ Foldkit decodes the detail → the user’s Message. - Services (renderer, frame source, resource registry) come from an adapter-built Layer passed to
lifecycle(...). The app builds it once at its entry boundary, so shared resources span every scene in the app (D5). - The custom element tag is registered only when a browser registry exists, guarded by
customElements.get, so the code is SSR- and HMR-safe.
Consequences
- Only public Foldkit APIs are used, and the lifecycle is owned and interrupted by Foldkit’s runtime.
- Mount finalisers run asynchronously after snabbdom’s
destroy. All teardown must be self-contained (no ordering assumptions about sibling DOM). - We need a structural equality fast path for descriptions. Reference equality isn’t enough.
- Done (Phase 3): events are dropped while
viewStateChangesisPaused(DevTools time travel), checked by the pause page ine2e/visual.test.ts.
Implementation notes (Phase 3)
The shipped API, in packages/effect-three-foldkit:
// entry: once per appconst environment = EffectThreeFoldkit.makeEnvironment(window);const sceneMount = EffectThreeFoldkit.makeSceneMount(environment);
// viewEffectThreeFoldkit.view( { scene: sceneFor(model), mount: sceneMount, onLifecycle: (lifecycle) => GotSceneLifecycle({ lifecycle }), onClick: ({ key }) => ClickedNode({ key }), }, h,);What changed from the decision above:
- No
customElements.define. The Mount intercepts thesceneproperty directly, so there’s no element class to register, re-register under HMR, or guard during SSR. - One DOM event per pointer kind (
scene-click,scene-pointer-enter, and so on, plusscene-error). Apps bind only the handlers they pass; unbound events dispatch nothing. - Lifecycle Messages are the library’s own (
StartedScene,FailedScene) and are lifted into the app’s union withMount.mapMessagethroughonLifecycle. - Services are provided with
provideService, not a Layer. A Layer provided toSceneRuntime.makewould be released as soon asmakereturned. - WebGL contexts are released on unmount with
forceContextLoss(). The browser test found that without it, Chromium starts dropping contexts after about 16 mount/unmount cycles. - Scene values are Schema-decoded at the property boundary (
MalformedSceneon failure), because the DOM property isunknown. Benchmark this in Phase 5.