Overhaul SnapLine graph ownership and input routing - #54
Merged
Conversation
Removes code with no reachable callers anywhere in core, adapters, demos, website, tests, or docs, and collapses public surfaces that offered two ways to do one thing (SNAPZEN: "there should only be one way to do something"). Unreachable methods: ConnectorMirror.requestDomGeometrySync, writeAllLinesNow, findClosestConnector, findClosestConnectorAtPoint, hoverWhileDragging, startPickUpLine, disconnectFromConnector; LineMirror.setLineStartAtConnector/setLineEndAtConnector; NodeMirror.setUpPosition. Unread state: GraphMirror.reconcilerActive (its comment described an authority gate that no longer exists), ConnectorMirror.#targetConnector and its accessors, numIncomingLines/numOutgoingLines, the never-populated LineChangeRequest.originalEvent, and 4 of 7 ReconciliationError codes that nothing emits. Dead accessors and no-op overrides: the `callbacks` setters on ConnectorMirror and NodeMirror (adapters use updateConfig or in-place mutation), NodeMirror.writeTransformRecursive and GroupNodeMirror.writeTransformAndLines (pure super passthroughs — the former carried a comment describing a re-glue it never performed; the re-glue actually happens via the sibling scheduleLineWrites in scheduleTransformAndLines), and RectSelectController.onCollideNode. Collapses LineMirror's seven positioning methods to three: setLineStart, setLineEnd, setLinePosition, endWorldX/endWorldY, and the moveLineToConnectorTransform alias are gone in favour of setLineStartAnchor/setLineEndAnchor/updateAnchors. cloneAnchor was defined byte-identically in line.ts and connector.ts; connector.ts now imports it. Removes the query.ts free functions and the package.json subpath exports map (both gave every symbol a second import path with no consumers). Adds GraphQuery.selection() first — getSelectedNodes was the only one of the four with no query() equivalent, so deleting it outright would have removed the sole public way to read the selection. Docs updated for every removed symbol. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Domain entities and the public contract stay directly under src/; the
registry, reconciler, and global.data machinery move to src/internal/.
Three modules mixed public API with internals, so a straight directory move
would have pushed public API into internal/. They are split on that seam
first:
types.ts NEW — pure types, zero imports: NodeId/ConnectorId/
LineId, ReconciliationError, GraphBatch, GeometryWriter
(absorbing the 2-line geometry.ts), and the whole
controlled-graph contract (LineRecord, LineChangeRequest,
ProposedLine, LineEndpointUpdate, CanonicalGraphSnapshot,
ControlledGraphCallbacks/Handle).
controlled-graph.ts NEW — attachControlledGraph, the package's headline
entry point, previously declared next to an internal
global.data accessor bag.
internal/graph-registry.ts the registry class + mintDomainId +
GraphReconcilerLike (was graph-mirror.ts)
internal/line-reconciler.ts the LineReconciler algorithm only
internal/shared-data.ts SnapLineSharedData, SourceSurfaceOwner,
snapData, getResizeHandles, getSourceSurfaces,
getGraphRegistry (was snapline-globals.ts)
Renames that ride along, both fixing names that described the wrong thing:
GraphMirror -> GraphRegistry (it is a registry *of* mirrors, and the suffix
collided with the NodeMirror/ConnectorMirror/LineMirror entity convention —
AGENTS.md already called it "the per-engine registry"), and its accessor
getGraphMirror -> getGraphRegistry. snapline-globals.ts was the only file
named after a storage location rather than a concept.
getGraphRegistry is now exported from index.ts. Four unit specs previously
deep-imported it by relative path while AGENTS.md advertised it as a package
export; they now import from the package root, so the move is invisible to
them.
Also updates the two engine-core comments in src/input.ts that tell
contributors to keep the resizeHandles/sourceSurfaces structural types in
sync with a file that no longer exists under that name.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds a multicast observation channel so a library outside SnapLine can keep
something (a text overlay, a badge) glued to a line or node while it moves.
This was previously impossible: LineMirror.#geometryWriter is a single field
already owned by <Line>, so a consumer calling bindGeometryWriter would not
add a watcher but silently replace the renderer; NodeMirror had no
subscription channel at all, only a single-slot #callbacks object the
adapters take over wholesale on mount.
LineMirror.onGeometryInvalidated / NodeMirror.onGeometryInvalidated are
Set-backed registrars returning an unsubscribe, matching the existing
onStateChange shape.
The signal is an INVALIDATION, not a paint hook. It fires synchronously
during input dispatch, before any frame task is queued, and carries no
geometry. Invoking it from inside writeTransform() would have silently
decided the subscriber's write phase for them — WRITE_2, with no way to opt
into a read stage or a later write stage, and nothing in the signature
saying so. Instead the subscriber schedules its own task with the existing
schedule(cb, { stage, queueId }) primitive and reads geometrySnapshot()
there. Per SNAPZEN: expose the primitives, no magic abstraction.
Consequences the docs have to state, and the unit spec pins: a line paints
at WRITE_2 with updateAnchors() inside that same task, so an overlay wanting
this frame's position schedules at WRITE_3. There is no priming call —
nothing is invalidated at subscribe time, so read geometrySnapshot()
directly for the initial position. Observer throws are isolated per
subscriber so third-party code cannot blank the graph.
Fire sites are consolidated rather than sprinkled. Line writes now funnel
through LineMirror.invalidateGeometry() (deferred) and
invalidateGeometryNow() (synchronous), each notifying before it queues;
connector/node schedulers became loops over those. On the node side the two
scheduling entry points cover it, and because scheduleTransformAndLines
already walks #transformNodeTree(), multi-select peers and group-carried
members each get their own signal — three cases NodeCallbacks.onDrag misses,
along with camera edge-pan.
Adds NodeMirror.geometrySnapshot() sourced from #authoredWidth/#authoredHeight
rather than leaving consumers to reach for the public hitBox, which is DOM
truth a ResizeObserver-scheduled READ_1 overwrites with the previously
rendered box — reading it mid-gesture pairs last frame's height with this
frame's y.
Renames NodeCallbacks.onGeometryChanged -> onGeometryCommit. It is
gesture-end only, and sitting beside a per-frame onGeometryInvalidated the
old name was a trap; the pair now reads unambiguously.
Adds tests/ut/snapline-observers.spec.ts and a test:snapline-ut script (the
SnapLine unit specs previously had no npm entry point).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
onLineChangeRequest now RETURNS the line list that should be canonical, and the bridge hands it straight to setCanonicalGraph. The `lines` prop is gone from both adapters, along with both queueMicrotask pushes and GraphRegistry.pendingGestureRequest. Why the microtask existed, and why returning removes the need for it: a dropped line is staged — visually targeted, no topology commitment — so its only exit is the next reconcile pass, which either settles it or discards it in the end-of-pass sweep. The reconciler has no pull channel. If the app rejected by doing nothing, no prop changed, no effect fired, no push happened, and the staged line hung forever; the unconditional post-request push closed that hole. Making the handler return the list closes it structurally instead, and takes three fragilities with it: the React adapter's correctness rested on an inference about React internals (that the handler's setState flushes and re-renders the props ref before microtasks drain — true for React 18 discrete events, not a contract); an ordering hazard where the decisive pass could be queued ahead of the adapter's push; and pendingGestureRequest, which gated nothing and only warned about a stalled push whose failure mode no longer exists. Rejection is now `return lines` — strictly better than the old bare `return`, because "I reject" and "I forgot" stop being the same code. The return type is non-optional so the second one is a type error. Adds applyLineChange(lines, request) to core, replacing a reducer that was byte-identical across nine sites (three doc variants, migration notes, the website demo, two Svelte demos, two React demos). Both adapters gain the imperative handle the protocol always needed for changes with no originating request — hydration/load, undo/redo, collaboration. Svelte exports setLines/flush (it exported nothing before); React is now forwardRef<ControlledGraphHandle> instead of a plain function returning null, which also makes AGENTS.md's claim about forwarded refs true. Consumers migrated. Note the React idiom the synchronous return forces: the document lives in a ref, because a functional setState has not produced the next list by the time the handler must return it. Svelte demos move to $state.raw, since applyLineChange replaces rather than mutates. The demos' addDocLine stays app code rather than becoming a core helper: its "replace any record sharing toConnectorId" step is single-capacity policy the application owns, not a request being applied. Test harness now stands in for a document: it tracks the records, defaults to rejection (return unchanged), and offers respondWith for accept/normalize. All 33 unit and 58 e2e tests pass with no assertion changes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two hooks that together close the gap between a line's preview and its settled form. resolveNewLine seeds application data onto a line the moment a drag creates it. Until now a settled line could be styled from LineRecord.payload, but a line being *dragged* had none — setPayload only runs when the reconciler settles or creates from a record, so the only thing a preview renderer could reach was line.start.metadata: undocumented, undiscoverable, and connector-level rather than per-gesture. The seam is the else-branch of #onDragStart, which runs only for a genuinely new line, so a seed placed there structurally cannot clobber payload the application already owns on a reconnect — covered by a test that reconnects a settled line and asserts the resolver did not fire again. Because the payload already rides into request.add at drop, this one hook closes three things at once: preview styling, complete minted records for a custom record type, and preview→settled continuity (the app keeps what it was handed instead of deriving something different at commit). It lives on the node — one place for all its ports, with the connector on the event so it can branch — and a connector may override it, matching how adapter and consumer callbacks already compose. Named for *when* it fires: "New" is load-bearing, since the seam never runs for a reconnect. resolveLineComponent picks a renderer per line, so a data edge and a control edge leaving the same node can look different — previously impossible, since the renderer was one prop on <Node>. Two levels, not four: the per-line resolver is the primitive and can express any coarser tier, so a connector-level prop would be a second way to do the same thing. Resolved at RENDER time, not at line creation. Hydration never runs the creation callback — a reloaded graph builds its lines through the reconciler — so resolving from the line is what makes a line you just drew and the same line after a refresh render identically. That is also why the discriminator must be serializable: store `kind: "data"` in the payload and map it to a component in the resolver; a component reference in a record hydrates as an empty object after one save/reload round-trip. Also introduces ResolvedNodeConfig so resolveNewLine stays optional rather than being forced into DEFAULT_NODE_CONFIG: it seeds application data, and per SNAPZEN there is no sensible default for that — its absence is the meaningful state. Note check:adapters, not typecheck, is what catches type errors in assets/snapline — the root tsconfig only includes src and demo. Core is checked transitively through the Svelte adapter. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#endControlledDrop took the same branch for "dropped where no connector was
resolved" and "dropped on a connector that declined", so both reached
consumers as a bare onDragEnd({ connected: false }). They are different
answers: only the first means the user gestured toward a node that does not
exist yet, which is the prerequisite for letting SnapLine request node
creation.
Adds DragEndOutcome ("connected" | "refused" | "empty-space" | "cancelled")
to the onDragEnd payload. This is useful on its own — a consumer can now tell
a rejected connection from a miss without re-running hit resolution — and it
is the branch a node-change channel will hang off.
Also aligns the console warning with the component name. It said "no graph
owner attached" while the thing to mount is called <ControlledGraph>, which
was the concrete naming bug behind the (now declined) rename question.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nnel Closes the last open item of the controlled-graph pass. There is no onNodeChangeRequest. The application mounts the node AND pushes the line record itself, using onDragEnd's new outcome plus the ControlledGraph handle. The symmetric channel was appealing but could not be made honest. A node request has to answer "which connector does the new line attach to?", and the application cannot answer truthfully at return time: Svelte and React both mount asynchronously, so the connector does not exist yet. Every shape that bridged the gap either accepted a one-frame flicker (return the id, let the record go latent, let the end-of-pass sweep discard the preview meanwhile) or added a "pending" grace state to the reconciler with its own cancel/timeout rule. Both invent machinery to hide the fact that the application, not SnapLine, decides when a node exists. SNAPZEN stanza 1 settles it: expose the primitives, let the developer combine them. What was genuinely missing was the information, not the channel — and that shipped in the previous commit as DragEndOutcome. The record naming an unmounted connector stays latent and converges when registration schedules the next pass, which is already documented reconciler behavior; no new state. Covered by a unit test that drives the whole flow: an empty-space drop, a refusal, then mounting the node and watching the latent record converge. That test also pins a distinction worth knowing — a role-ineligible connector (maxIncoming: 0) is filtered out during candidate resolution, so dropping on one reports "empty-space", not "refused". A refusal is a connector that resolves as a candidate and then declines at drop, which is exactly what isValidConnection's phase parameter is for. TODO.md records the decision and unblocks both dependent features: spawn-on-empty-drop now needs only a demo, and node-dropped-on-line needs a hit test rather than a request channel. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Brings the written contracts in line with what shipped, and proves the observation channel works in a browser rather than only in unit tests. AGENTS.md: codifies SNAPZEN as a "Design rules" section, so a reviewer has a written basis for rejecting a convenience API that duplicates a primitive rather than merely debating it — with the three rules annotated by what they have already decided in this arc. Rewrites the callback-conventions section around the distinction that actually exists (notification vs registrar vs value-returning policy, and why onLineChangeRequest is the one deliberate exception). Adds the observation-vs-painting rule to the DOM ownership contract. Replaces the Node.svelte props block, which listed 3 of ~20. ownership-specification.md: adds A7 (a change request carries its own answer — no dependence on framework flush timing, exactly one decisive pass per request, synchronous handler, imperative handle for non-request pushes) and A8 (observation is not painting — single-owner writer, multicast pre-queue signal carrying no geometry, core picks no write phase, throws isolated, no adapter prop). Four new conformance rows. Reference pages: all three line.mdx now document onGeometryInvalidated with the stage-ordering table (a line paints at WRITE_2, so an overlay wanting this frame's position schedules WRITE_3), onStateChange, and the standing question of where a line's DOM lives — SnapLine never holds it. New tests/e2e/snapline-overlay.spec.ts drives a marker glued to a line's midpoint by an overlay component that did not create the line and does not paint it. Two findings from making it honest: - The first version's measurement was wrong, not the feature: it compared a container-local midpoint against a screen-space box, giving a CONSTANT 95px drift across every frame of a drag — which was itself the tell that tracking worked. It now reads the painted path's bounding box, whose centre is exactly the midpoint because the default cubic is point-symmetric about it. - The resize case originally passed with the channel removed, so it was proving nothing: EdgeNode was not resizable, and a bottom-right resize would not move a left-edge connector anyway. Node B is now resizable and the test grabs the TOP-LEFT handle, so the anchored edge — and the line endpoint — actually travels. Verified by sabotage: with the subscription removed both gesture tests go red, while the two mount-time tests correctly still pass. Also extends lint from `src` to `src assets/snapline` (it had never covered the adapters) and fixes the 7 prefer-const errors that surfaced. TODO.md records the remaining gate gaps: tsc still excludes assets/**, asset-base has 6 lint errors blocking a repo-wide sweep, and tests/ut/input.spec.ts has been broken on main. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Add native pointer capture handoff and resilient gesture finalization, migrate SnapLine connectors to DOM-owned input with collision point queries, and update SnapSort integration, adapters, documentation, and regression coverage.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
LineRecord[]the canonical SnapLine topology through an engine-scoped controlled-graph reconcilerWhy
The previous topology and input paths mixed engine-owned runtime state with application-owned document state. That made acceptance and rejection depend on framework timing, allowed ambiguous gesture ownership, and coupled geometry updates to framework renders. This overhaul gives each layer a single ownership contract: applications own topology, SnapLine owns runtime mirrors and geometry, frameworks own structural DOM, and SnapEngine owns input routing.
Impact
This is a pre-1.0 breaking SnapLine API change. Consumers should migrate from
EdgeSyncand imperative topology methods toControlledGraph/attachControlledGraph, provide stable connector and line IDs for persistent graphs, userulesfor connector policy, and render explicitResizeRegioncomponents where resizing is supported. The included migration notes and framework reference docs cover the API mapping.Validation
npm run cinpm run test:snapline-ut— 43 passednpm run test:snapline— 14 passednpm run test:snapline-edges— 7 passednpm run test:snapline-group— 7 passednpm run test:snapline-overlay— 4 passedRepository-wide Svelte checks report existing initial-value warnings but no errors.