Merge pull request #5596 from xyflow/chore/svelte-typed-context

Chore/svelte typed context
This commit is contained in:
Peter Kogo
2025-12-03 16:12:27 +01:00
committed by GitHub
28 changed files with 1300 additions and 1164 deletions

View File

@@ -1,29 +1,34 @@
import globals from 'globals';
import prettier from 'eslint-config-prettier';
import { fileURLToPath } from 'node:url';
import { includeIgnoreFile } from '@eslint/compat';
import js from '@eslint/js';
import ts from 'typescript-eslint';
import svelte from 'eslint-plugin-svelte';
import eslintConfigPrettier from 'eslint-config-prettier';
import { defineConfig } from 'eslint/config';
import globals from 'globals';
import ts from 'typescript-eslint';
import svelteConfig from './svelte.config.js';
export default ts.config(
const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
export default defineConfig(
includeIgnoreFile(gitignorePath),
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs.recommended,
eslintConfigPrettier,
prettier,
...svelte.configs.prettier,
{
languageOptions: {
ecmaVersion: 2020,
sourceType: 'module',
globals: {
...globals.browser,
...globals.es2017,
...globals.node
}
globals: { ...globals.browser, ...globals.node }
},
rules: {
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
'no-undef': 'off'
}
},
{
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
ignores: ['eslint.config.js', 'svelte.config.js'],
languageOptions: {
parserOptions: {
projectService: true,
@@ -32,8 +37,5 @@ export default ts.config(
svelteConfig
}
}
},
{
ignores: ['.svelte-kit/*', 'build/*', 'dist/*'] // Add common ignore patterns
}
);

View File

@@ -55,35 +55,36 @@
"@xyflow/system": "workspace:*"
},
"devDependencies": {
"@eslint/js": "^9.28.0",
"@sveltejs/adapter-auto": "^6.0.1",
"@sveltejs/kit": "^2.21.4",
"@sveltejs/package": "^2.3.11",
"@sveltejs/vite-plugin-svelte": "^5.1.0",
"@typescript-eslint/eslint-plugin": "^8.34.0",
"@typescript-eslint/parser": "^8.34.0",
"autoprefixer": "^10.4.21",
"cssnano": "^7.0.7",
"dotenv": "^16.5.0",
"eslint": "^9.28.0",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-svelte": "^3.9.2",
"globals": "^16.2.0",
"postcss": "^8.5.4",
"@eslint/compat": "^2.0.0",
"@eslint/js": "^9.39.1",
"@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/kit": "^2.49.1",
"@sveltejs/package": "^2.5.7",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@typescript-eslint/eslint-plugin": "^8.48.1",
"@typescript-eslint/parser": "^8.48.1",
"autoprefixer": "^10.4.22",
"cssnano": "^7.1.2",
"dotenv": "^17.2.3",
"eslint": "^9.39.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.13.0",
"globals": "^16.5.0",
"postcss": "^8.5.6",
"postcss-cli": "^11.0.1",
"postcss-combine-duplicated-selectors": "^10.0.3",
"postcss-import": "^16.1.0",
"postcss-import": "^16.1.1",
"postcss-nested": "^7.0.2",
"postcss-rename": "^0.8.0",
"prettier": "^3.5.3",
"prettier": "^3.7.4",
"prettier-plugin-svelte": "^3.4.0",
"svelte": "^5.33.19",
"svelte-check": "^4.2.1",
"svelte-eslint-parser": "^1.2.0",
"svelte": "^5.45.4",
"svelte-check": "^4.3.4",
"svelte-eslint-parser": "^1.4.0",
"svelte-preprocess": "^6.0.3",
"tslib": "^2.8.1",
"typescript": "^5.8.3",
"typescript-eslint": "^8.34.0"
"typescript": "^5.9.3",
"typescript-eslint": "^8.48.1"
},
"peerDependencies": {
"svelte": "^5.25.0"

View File

@@ -1,11 +1,11 @@
<script lang="ts">
import { getContext } from 'svelte';
import { getEdgeIdContext } from '$lib/store/context';
import { hideOnSSR, portal } from '$lib/actions/portal';
import { useStore } from '$lib/store';
import type { EdgeLabelProps } from './types';
import { toPxString } from '$lib/utils';
import type { EdgeLabelProps } from './types';
let {
x = 0,
y = 0,
@@ -19,13 +19,12 @@
}: EdgeLabelProps = $props();
const store = useStore();
const edgeId = getContext<string>('svelteflow__edge_id');
if (!edgeId) {
throw new Error('EdgeLabel must be used within an edge');
}
const edgeId = getEdgeIdContext('EdgeLabel must be used within a Custom Edge component');
let z = $derived(store.visible.edges.get(edgeId)?.zIndex);
let z = $derived.by(() => {
return store.visible.edges.get(edgeId)?.zIndex;
});
</script>
<div
@@ -40,7 +39,7 @@
style:z-index={z}
tabindex="-1"
onclick={() => {
if (selectEdgeOnClick) store.handleEdgeSelection(edgeId);
if (selectEdgeOnClick && edgeId) store.handleEdgeSelection(edgeId);
}}
{...rest}
>

View File

@@ -1,8 +1,8 @@
<script lang="ts">
import { useStore } from '$lib/store';
import { getEdgeIdContext } from '$lib/store/context';
import type { Edge } from '$lib/types';
import { XYHandle, type HandleType, type OnConnectStart } from '@xyflow/system';
import { getContext } from 'svelte';
import { EdgeLabel } from '../EdgeLabel';
import type { EdgeReconnectAnchorProps } from './types';
@@ -19,11 +19,9 @@
const store = useStore();
let edgeId: string | undefined = getContext('svelteflow__edge_id');
if (!edgeId) {
throw new Error('EdgeReconnectAnchor must be used within an Edge component');
}
const edgeId = getEdgeIdContext(
'EdgeReconnectAnchor must be used within a Custom Edge component'
);
const onPointerDown = (event: PointerEvent) => {
if (event.button !== 0) {

View File

@@ -1,8 +1,8 @@
<script lang="ts" generics="NodeType extends Node = Node, EdgeType extends Edge = Edge">
import { setContext } from 'svelte';
import { elementSelectionKeys, getMarkerId } from '@xyflow/system';
import { setEdgeIdContext } from '$lib/store/context';
import { BezierEdgeInternal } from '$lib/components/edges';
import type { Node, EdgeLayouted, Edge, EdgeEvents } from '$lib/types';
@@ -22,6 +22,7 @@
} & EdgeEvents<EdgeType> = $props();
let {
id,
source,
target,
sourceX,
@@ -51,12 +52,12 @@
ariaLabel
} = $derived(edge);
// svelte-ignore state_referenced_locally
setEdgeIdContext(id);
// svelte-ignore non_reactive_update
let edgeRef: SVGGElement | null = null;
const { id } = edge;
setContext('svelteflow__edge_id', id);
let selectable = $derived(_selectable ?? store.elementsSelectable);
let focusable = $derived(_focusable ?? store.edgesFocusable);

View File

@@ -1,5 +1,4 @@
<script lang="ts">
import { getContext } from 'svelte';
import {
Position,
XYHandle,
@@ -16,8 +15,8 @@
import { useStore } from '$lib/store';
import type { ConnectableContext } from '../NodeWrapper/types';
import type { HandleProps } from './types';
import { getNodeConnectableContext, getNodeIdContext } from '$lib/store/context';
let {
id: handleId = null,
@@ -35,8 +34,10 @@
...rest
}: HandleProps = $props();
const nodeId = getContext<string>('svelteflow__node_id');
const isConnectableContext = getContext<ConnectableContext>('svelteflow__node_connectable');
const nodeId = getNodeIdContext('Handle must be used within a Custom Node component');
const isConnectableContext = getNodeConnectableContext(
'Handle must be used within a Custom Node component'
);
let isTarget = $derived(type === 'target');
let isConnectable = $derived(

View File

@@ -1,5 +1,5 @@
<script lang="ts" generics="NodeType extends Node = Node, EdgeType extends Edge = Edge">
import { setContext, onDestroy } from 'svelte';
import { onDestroy } from 'svelte';
import {
elementSelectionKeys,
errorMessages,
@@ -10,13 +10,15 @@
} from '@xyflow/system';
import drag from '$lib/actions/drag';
import DefaultNode from '$lib/components/nodes/DefaultNode.svelte';
import type { ConnectableContext, NodeWrapperProps } from './types';
import type { Node, Edge, NodeEvents } from '$lib/types';
import { setNodeConnectableContext, setNodeIdContext } from '$lib/store/context';
import { arrowKeyDiffs, toPxString } from '$lib/utils';
import { ARIA_NODE_DESC_KEY } from '../A11yDescriptions';
import type { Node, Edge, NodeEvents } from '$lib/types';
import type { ConnectableContext, NodeWrapperProps } from './types';
import DefaultNode from '$lib/components/nodes/DefaultNode.svelte';
let {
store = $bindable(),
node,
@@ -95,8 +97,9 @@
return connectable;
}
};
setContext('svelteflow__node_connectable', connectableContext);
setContext('svelteflow__node_id', id);
setNodeIdContext(id);
setNodeConnectableContext(connectableContext);
if (process.env.NODE_ENV === 'development') {
$effect(() => {

View File

@@ -1,24 +0,0 @@
import { key } from '$lib/store';
import type { StoreContext } from '$lib/store/types';
import { getContext } from 'svelte';
/**
* Warns the user that they should use $derived() when calling a hook.
* This is not neccessarry when the hook is called inside a child of <SvelteFlowFlow />,
* however exceptions can be made if you don't want to return a closure.
* @param functionName - The name of the function that is being called
* @param force - If true, the warning will be shown regardless if child of <SvelteFlowFlow />
*/
export function derivedWarning(functionName: string) {
const storeContext = getContext<StoreContext>(key);
if (!storeContext) {
throw new Error(
`In order to use ${functionName}() you need to wrap your component in a <SvelteFlowProvider />`
);
}
if (storeContext.provider && typeof window === 'object' && !$effect.tracking()) {
throw new Error(`Use $derived(${functionName}()) to receive updates when values change.`);
}
}

View File

@@ -8,7 +8,7 @@ import {
} from '@xyflow/system';
import { useStore } from '$lib/store';
import { getContext } from 'svelte';
import { getNodeIdContext } from '$lib/store/context';
type UseNodeConnectionsParams = {
id?: string;
@@ -42,7 +42,7 @@ export function useNodeConnections({
}: UseNodeConnectionsParams = {}) {
const { edges, connectionLookup } = $derived(useStore());
const contextNodeId = getContext<string>('svelteflow__node_id');
const contextNodeId = getNodeIdContext();
const nodeId = id ?? contextNodeId;
let connectionMaps: { previous: ConnectionMap; next: ConnectionMap } = {

View File

@@ -2,7 +2,6 @@ import { getContext } from 'svelte';
import type { StoreContext, SvelteFlowStore } from '../store/types';
import { key } from '../store';
import { derivedWarning } from './derivedWarning.svelte';
import type { Node, Edge } from '$lib/types';
export function useStore<
@@ -17,9 +16,5 @@ export function useStore<
);
}
if (process.env.NODE_ENV === 'development') {
derivedWarning('useStore');
}
return storeContext.getStore();
}

View File

@@ -1,6 +1,6 @@
/* eslint-disable svelte/prefer-svelte-reactivity */
import { useStore } from '$lib/store';
import { getContext } from 'svelte';
import { getNodeIdContext } from '$lib/store/context';
/**
* When you programmatically add or remove handles to a node or update a node's
@@ -14,7 +14,7 @@ import { getContext } from 'svelte';
*/
export function useUpdateNodeInternals(): (nodeId?: string | string[]) => void {
const { domNode, updateNodeInternals } = $derived(useStore());
const nodeId = getContext('svelteflow__node_id') as string | undefined;
const nodeId = getNodeIdContext();
// @todo: do we want to add this to system?
const updateInternals = (id?: string | string[]) => {

View File

@@ -2,9 +2,10 @@
import { getEdgeToolbarTransform } from '@xyflow/system';
import { useStore } from '$lib/store';
import { getEdgeIdContext } from '$lib/store/context';
import { EdgeLabel } from '$lib/components/EdgeLabel';
import type { EdgeToolbarProps } from './types';
import { getContext } from 'svelte';
let {
x,
@@ -19,11 +20,8 @@
}: EdgeToolbarProps = $props();
const store = useStore();
const edgeId = getContext<string>('svelteflow__edge_id');
if (!edgeId) {
throw new Error('EdgeToolbar must be used within an edge');
}
const edgeId = getEdgeIdContext('EdgeToolbar must be used within an edge');
const isActive = $derived(
typeof isVisible === 'boolean' ? isVisible : store.edgeLookup.get(edgeId)?.selected

View File

@@ -1,6 +1,8 @@
<script lang="ts">
import { getContext, onMount } from 'svelte';
import { onMount } from 'svelte';
import { useStore } from '$lib/store';
import { getNodeIdContext } from '$lib/store/context';
import {
XYResizer,
ResizeControlVariant,
@@ -9,8 +11,8 @@
type XYResizerChange,
type XYResizerChildChange
} from '@xyflow/system';
import type { ResizeControlProps } from './types';
import type { Node } from '$lib/types';
let {
nodeId,
@@ -34,10 +36,14 @@
}: ResizeControlProps = $props();
const store = useStore();
const contextNodeId = getNodeIdContext();
let id = $derived(
typeof nodeId === 'string' ? nodeId : getContext<string>('svelteflow__node_id')
);
let id = $derived(typeof nodeId === 'string' ? nodeId : contextNodeId);
// svelte-ignore state_referenced_locally
if (!id) {
throw new Error('Either pass a nodeId or use within a Custom Node component');
}
let resizeControlRef: HTMLDivElement;
let resizer: XYResizerInstance | null = $state(null);
@@ -72,7 +78,7 @@
changes.set(id, change);
for (const childChange of childChanges) {
changes.set(childChange.id, {x: childChange.position.x, y: childChange.position.y });
changes.set(childChange.id, { x: childChange.position.x, y: childChange.position.y });
}
store.nodes = store.nodes.map((node) => {
@@ -81,7 +87,7 @@
const vertical = !resizeDirection || resizeDirection === 'vertical';
if (change) {
return {
return {
...node,
position: {
x: horizontal ? (change.x ?? node.position.x) : node.position.x,

View File

@@ -53,7 +53,6 @@ export type NodeResizerProps = {
export type ResizeControlProps = Pick<
NodeResizerProps,
| 'nodeId'
| 'color'
| 'minWidth'
| 'minHeight'
@@ -76,5 +75,7 @@ export type ResizeControlProps = Pick<
* @example ResizeControlVariant.Handle, ResizeControlVariant.Line
*/
variant?: ResizeControlVariant;
/** nodeId must be provided when used outside a custom node */
nodeId?: string;
children?: Snippet;
} & HTMLAttributes<HTMLDivElement>;

View File

@@ -1,9 +1,9 @@
<script lang="ts">
import { getContext } from 'svelte';
import { Position, getNodeToolbarTransform } from '@xyflow/system';
import { hideOnSSR, portal } from '$lib/actions/portal';
import { useStore } from '$lib/store';
import { getNodeIdContext } from '$lib/store/context';
import { hideOnSSR, portal } from '$lib/actions/portal';
import { useSvelteFlow } from '$lib/hooks/useSvelteFlow.svelte';
import type { InternalNode } from '$lib/types';
@@ -22,7 +22,7 @@
const store = useStore();
const { getNodesBounds } = useSvelteFlow();
const contextNodeId = getContext<string>('svelteflow__node_id');
const contextNodeId = getNodeIdContext();
let toolbarNodes: InternalNode[] = $derived.by(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
@@ -30,6 +30,9 @@
const nodeIds = Array.isArray(nodeId) ? nodeId : [nodeId ?? contextNodeId];
return nodeIds.reduce<InternalNode[]>((res, nodeId) => {
if (!nodeId) {
throw new Error('Either pass a nodeId or use within a Custom Node component');
}
const node = store.nodeLookup.get(nodeId);
if (node) {

View File

@@ -0,0 +1,35 @@
import type { ConnectableContext } from '$lib/components/NodeWrapper/types';
import { setContext, getContext, hasContext } from 'svelte';
/**
* Creates a type-safe context getter and setter pair.
* Extended from Svelte's official createContext pattern.
* - When called with an error message string, it throws if the context is not set
* - When called without arguments, it returns the context value or undefined
*/
function createContext<T>(): [
{
(errorMessage: string): T;
(): T | undefined;
},
(context: T) => T
] {
const key = {};
return [
(errorMessage?: string) => {
if (errorMessage && !hasContext(key)) {
throw new Error(errorMessage);
}
return getContext(key);
},
(context) => setContext(key, context)
];
}
export const [getNodeIdContext, setNodeIdContext] = createContext<string>();
export const [getNodeConnectableContext, setNodeConnectableContext] =
createContext<ConnectableContext>();
export const [getEdgeIdContext, setEdgeIdContext] = createContext<string>();

View File

@@ -36,8 +36,10 @@ export interface EdgeLayoutBaseOptions<NodeType extends Node = Node, EdgeType ex
onerror: OnError;
}
export interface EdgeLayoutAllOptions<NodeType extends Node = Node, EdgeType extends Edge = Edge>
extends EdgeLayoutBaseOptions<NodeType, EdgeType> {
export interface EdgeLayoutAllOptions<
NodeType extends Node = Node,
EdgeType extends Edge = Edge
> extends EdgeLayoutBaseOptions<NodeType, EdgeType> {
onlyRenderVisible: never;
visibleNodes: never;
transform: never;