Hand a shared port type on through a chain of nodes (#210)
Since #207 the output of First not empty or Select takes the type
its inputs agree on. A second node of that kind behind the first
stayed any, because the first one answered with the any it was
registered with. That was the guard against a circle of such nodes
asking each other forever. The issue asks for the type to travel
through the chain. Answered there: measure with 50 nodes and build
no cache, and the painted input pins follow along.
- shapedDef in core/graph/nested.ts takes a Trail in place of the
flag inherit. Its set open holds the nodes still being asked. A
node reached a second time reports the any it was registered
with, which ends a circle.
- The map done of the Trail keeps each answer of one question. A
node feeding both inputs of the next is asked once, so the work
does not double with every link of a chain.
- ownShape is the part of shapedDef that builds the ports before a
shared group has its say. It is split off, nothing in it changed.
- Inputs stay any and only outputs inherit, as in #207. An edge
that turns invalid further down is left in place and drawn in the
danger colour.
- paintedInputs uses one Trail for all pins of a node, so the pins
show the type handed on through the chain.
- Nothing is cached between two changes. A chain of 50 resolved in
about 10 ms where it took 2 ms before, measured earlier in this
round and not again for this commit.
Tests:
- Gate on melektaus-atx: 211 files, 3240 tests (3227 + 13), exit 0
- e2e 1-6 on melektaus-atx: 298 checks, all pass
- e2e 4,6,18,19,20,23,24,26,28,31,36,43,53: 558 checks, all pass
- No full run and no smokes. The sum of 1673 (1671 + 2) is reckoned,
1671 is what CI counted for c88f57c.
- Fourteen unit tests in shared-port-type.test.ts: a chain of two
and of three, mixed kinds, a mixed and an open input, undo at the
front, a circle of two, a node feeding itself, behind a circle, an
invalid edge further down, the chain of 40 asked once per node,
the chain of 50, and two for the pins. The test saying the type
does not pass through a second node is gone, hence 13 more.
- Two e2e checks in module 06: the second node takes number, and a
text at the front opens the whole chain again.
- Four unit counter-probes and one browser counter-probe all fail
their tests. With the old behaviour only the chain check fails.
- Not checked in the browser: the circle and the chain of three,
which have unit tests only. Nothing was looked at after the build.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c88f57cf0a
commit
9ca220a285
4 files changed
+268
-39
No files matched your search
@@ -45,7 +45,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
|
||||
The browser suite and the three example games need a machine with a GPU:
|
||||
|
||||
```bash
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1671 checks (GM_STEPS=20 for one step)
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1673 checks (GM_STEPS=20 for one step)
|
||||
# every tool signs in first – see the Accounts page
|
||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||
|
||||
+39
-22
@@ -145,7 +145,7 @@ export function declaredPorts(graph: Graph | undefined, type: string): PortDef[]
|
||||
* type declares ports per node (`declaresPorts`) or computes them (`derivePorts`).
|
||||
*/
|
||||
export function resolveDef(node: GraphNode, graph: Graph, lookup: GraphLookup): NodeDef | undefined {
|
||||
const def = shapedDef(node, graph, lookup, true);
|
||||
const def = shapedDef(node, graph, lookup, newTrail());
|
||||
return def && splitDef(def, node);
|
||||
}
|
||||
|
||||
@@ -158,24 +158,41 @@ function splitDef(def: NodeDef, node: GraphNode): NodeDef {
|
||||
* produces its wholes as always; reading a field port picks the field out of one (`splitField`).
|
||||
*/
|
||||
export function runDef(node: GraphNode, graph: Graph, lookup: GraphLookup): NodeDef | undefined {
|
||||
return shapedDef(node, graph, lookup, true);
|
||||
return shapedDef(node, graph, lookup, newTrail());
|
||||
}
|
||||
|
||||
/**
|
||||
* `inherit` is off while answering what feeds a shared group: a node of that kind then reports the
|
||||
* `any` it was registered with, so two of them in a row cannot ask each other in circles (#207).
|
||||
* One question about a shared group, followed through the nodes of that kind in front of it (#210).
|
||||
* `open` holds the nodes still being asked: reaching one again is a data cycle, and it then reports
|
||||
* the `any` it was registered with, which ends the circle. `done` keeps each answer, so a node
|
||||
* feeding both inputs of the next is asked once and not once per path.
|
||||
*/
|
||||
function shapedDef(
|
||||
node: GraphNode,
|
||||
graph: Graph,
|
||||
lookup: GraphLookup,
|
||||
inherit: boolean,
|
||||
): NodeDef | undefined {
|
||||
interface Trail {
|
||||
open: Set<string>;
|
||||
done: Map<string, NodeDef>;
|
||||
}
|
||||
|
||||
function newTrail(): Trail {
|
||||
return { open: new Set(), done: new Map() };
|
||||
}
|
||||
|
||||
function shapedDef(node: GraphNode, graph: Graph, lookup: GraphLookup, trail: Trail): NodeDef | undefined {
|
||||
const def = nodeDef(node.type);
|
||||
if (!def) return undefined;
|
||||
const shared = inherit ? def.sharedType : undefined;
|
||||
if (!def.declaresPorts && !def.derivePorts)
|
||||
return shared ? withSharedType(def, shared, node, graph, lookup) : def;
|
||||
const shared = def.sharedType;
|
||||
if (!shared || trail.open.has(node.id)) return ownShape(def, node, graph, lookup);
|
||||
const known = trail.done.get(node.id);
|
||||
if (known) return known;
|
||||
trail.open.add(node.id);
|
||||
const typed = withSharedType(ownShape(def, node, graph, lookup), shared, node, graph, lookup, trail);
|
||||
trail.open.delete(node.id);
|
||||
trail.done.set(node.id, typed);
|
||||
return typed;
|
||||
}
|
||||
|
||||
/** The ports of this node before any shared group has its say. */
|
||||
function ownShape(def: NodeDef, node: GraphNode, graph: Graph, lookup: GraphLookup): NodeDef {
|
||||
if (!def.declaresPorts && !def.derivePorts) return def;
|
||||
const own = (side: 'inputs' | 'outputs') =>
|
||||
declaredOn(def, node.ports ?? [], side).map((p) =>
|
||||
def.declaredPort ? def.declaredPort(p, node, graph, lookup) : portDefOf(p),
|
||||
@@ -185,13 +202,12 @@ function shapedDef(
|
||||
const ports = [...def[key], ...(grown[key] ?? []), ...own(key)];
|
||||
return grown.inOrder ? ports : execFirst(ports);
|
||||
};
|
||||
const resolved = {
|
||||
return {
|
||||
...def,
|
||||
label: grown.label ?? def.label,
|
||||
inputs: side('inputs'),
|
||||
outputs: side('outputs'),
|
||||
};
|
||||
return shared ? withSharedType(resolved, shared, node, graph, lookup) : resolved;
|
||||
}
|
||||
|
||||
/** The group's outputs, retyped to what its inputs agree on; `any` leaves the definition alone. */
|
||||
@@ -201,8 +217,9 @@ function withSharedType(
|
||||
node: GraphNode,
|
||||
graph: Graph,
|
||||
lookup: GraphLookup,
|
||||
trail: Trail,
|
||||
): NodeDef {
|
||||
const type = agreedType(group.inputs.map((key) => feedingType(node, key, graph, lookup, false)));
|
||||
const type = agreedType(group.inputs.map((key) => feedingType(node, key, graph, lookup, trail)));
|
||||
if (type === 'any') return def;
|
||||
return {
|
||||
...def,
|
||||
@@ -211,21 +228,20 @@ function withSharedType(
|
||||
}
|
||||
|
||||
/**
|
||||
* What the edge into that input carries, or undefined while nothing is connected to it. `inherit`
|
||||
* says whether a shared group on the *feeding* node may have its say: off while a group asks what
|
||||
* feeds it, on when only the drawn colour is wanted, which is what the edge itself is drawn in.
|
||||
* What the edge into that input carries, or undefined while nothing is connected to it: the type
|
||||
* the feeding node resolves to, which is what the edge itself is drawn in.
|
||||
*/
|
||||
function feedingType(
|
||||
node: GraphNode,
|
||||
key: string,
|
||||
graph: Graph,
|
||||
lookup: GraphLookup,
|
||||
inherit: boolean,
|
||||
trail: Trail,
|
||||
): PortType | undefined {
|
||||
const edge = edgesInto(graph, node.id, key)[0];
|
||||
if (!edge) return undefined;
|
||||
const from = findNode(graph, edge.from.node);
|
||||
const def = from && shapedDef(from, graph, lookup, inherit);
|
||||
const def = from && shapedDef(from, graph, lookup, trail);
|
||||
return def && splitDef(def, from).outputs.find((p) => p.key === edge.from.port)?.type;
|
||||
}
|
||||
|
||||
@@ -236,9 +252,10 @@ function feedingType(
|
||||
*/
|
||||
function paintedInputs(node: GraphNode, graph: Graph, def: NodeDef, lookup: GraphLookup): PortDef[] | null {
|
||||
if (!def.inputs.some((port) => port.type === 'any' && !port.ghost)) return null;
|
||||
const trail = newTrail();
|
||||
return def.inputs.map((port) => {
|
||||
if (port.type !== 'any' || port.ghost) return port;
|
||||
const fed = feedingType(node, port.key, graph, lookup, true);
|
||||
const fed = feedingType(node, port.key, graph, lookup, trail);
|
||||
return fed === undefined || fed === 'any' || fed === 'exec' ? port : { ...port, paintType: fed };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -100,21 +100,6 @@ describe('First not empty, whose a, b and value carry the same value', () => {
|
||||
expect(may).toBe(true);
|
||||
});
|
||||
|
||||
it('does not pass the type on through a second one of its kind', () => {
|
||||
const f = fixture();
|
||||
const first = f.edit.addNode('FirstNotEmpty');
|
||||
const second = f.edit.addNode('FirstNotEmpty');
|
||||
f.edit.wire([
|
||||
[f.edit.addNode('Number'), 'value', first, 'a'],
|
||||
[f.edit.addNode('Number'), 'value', first, 'b'],
|
||||
[first, 'value', second, 'a'],
|
||||
[first, 'value', second, 'b'],
|
||||
]);
|
||||
expect(typeOf(f.behavior, first, 'value', f)).toBe('number');
|
||||
// A chain would have to resolve its neighbour to answer, and two of them would ask in circles.
|
||||
expect(typeOf(f.behavior, second, 'value', f)).toBe('any');
|
||||
});
|
||||
|
||||
it('leaves an edge that the new type makes invalid in place, for the canvas to mark', () => {
|
||||
const f = fixture();
|
||||
const node = f.edit.addNode('FirstNotEmpty');
|
||||
@@ -167,6 +152,184 @@ describe('Select, whose false, true and value carry the same value', () => {
|
||||
});
|
||||
});
|
||||
|
||||
type Fixture = ReturnType<typeof fixture>;
|
||||
|
||||
/** `count` First not empty in a row: the first fed by two literals, each next one by the one before. */
|
||||
function chain(f: Fixture, count: number, literal: string, kind = 'FirstNotEmpty'): GraphNode[] {
|
||||
const [a, b] = kind === 'Select' ? ['false', 'true'] : ['a', 'b'];
|
||||
const nodes = Array.from({ length: count }, () => f.edit.addNode(kind));
|
||||
f.edit.connect(f.edit.addNode(literal), 'value', nodes[0]!, a);
|
||||
f.edit.connect(f.edit.addNode(literal), 'value', nodes[0]!, b);
|
||||
for (let i = 1; i < count; i++) {
|
||||
f.edit.connect(nodes[i - 1]!, 'value', nodes[i]!, a);
|
||||
f.edit.connect(nodes[i - 1]!, 'value', nodes[i]!, b);
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/** Two First not empty feeding each other, each with a number on its other input. */
|
||||
function circle(f: Fixture): [GraphNode, GraphNode] {
|
||||
const first = f.edit.addNode('FirstNotEmpty');
|
||||
const second = f.edit.addNode('FirstNotEmpty');
|
||||
f.edit.wire([
|
||||
[f.edit.addNode('Number'), 'value', first, 'b'],
|
||||
[f.edit.addNode('Number'), 'value', second, 'b'],
|
||||
[first, 'value', second, 'a'],
|
||||
[second, 'value', first, 'a'],
|
||||
]);
|
||||
return [first, second];
|
||||
}
|
||||
|
||||
describe('a type handed on through several nodes with a shared group (#210)', () => {
|
||||
it('reaches the second node of a chain', () => {
|
||||
const f = fixture();
|
||||
const [first, second] = chain(f, 2, 'Number');
|
||||
expect(typeOf(f.behavior, first!, 'value', f)).toBe('number');
|
||||
expect(typeOf(f.behavior, second!, 'value', f)).toBe('number');
|
||||
});
|
||||
|
||||
it('reaches the third, and keeps every input on the way any', () => {
|
||||
const f = fixture();
|
||||
const nodes = chain(f, 3, 'String');
|
||||
expect(nodes.map((n) => typeOf(f.behavior, n, 'value', f))).toEqual(['string', 'string', 'string']);
|
||||
expect(nodes.map((n) => typeOf(f.behavior, n, 'a', f, 'inputs'))).toEqual(['any', 'any', 'any']);
|
||||
});
|
||||
|
||||
it('passes from one kind of node to another', () => {
|
||||
const f = fixture();
|
||||
const [pick] = chain(f, 1, 'Animation');
|
||||
const select = f.edit.addNode('Select');
|
||||
f.edit.wire([
|
||||
[pick!, 'value', select, 'false'],
|
||||
[f.edit.addNode('Animation'), 'value', select, 'true'],
|
||||
]);
|
||||
const animation = typeOf(f.behavior, pick!, 'value', f);
|
||||
expect(animation).not.toBe('any');
|
||||
expect(typeOf(f.behavior, select, 'value', f)).toBe(animation);
|
||||
});
|
||||
|
||||
it('is any behind a node whose inputs disagree, and untouched in front of it', () => {
|
||||
const f = fixture();
|
||||
const nodes = chain(f, 4, 'Number');
|
||||
// The second node now hears a number and a text.
|
||||
f.edit.connect(f.edit.addNode('String'), 'value', nodes[1]!, 'b');
|
||||
expect(nodes.map((n) => typeOf(f.behavior, n, 'value', f))).toEqual(['number', 'any', 'any', 'any']);
|
||||
});
|
||||
|
||||
it('is any behind a node with an input left open', () => {
|
||||
const f = fixture();
|
||||
const first = f.edit.addNode('FirstNotEmpty');
|
||||
const second = f.edit.addNode('FirstNotEmpty');
|
||||
f.edit.wire([
|
||||
[f.edit.addNode('Number'), 'value', first, 'a'],
|
||||
[first, 'value', second, 'a'],
|
||||
[first, 'value', second, 'b'],
|
||||
]);
|
||||
expect(typeOf(f.behavior, second, 'value', f)).toBe('any');
|
||||
});
|
||||
|
||||
it('falls back to any all along the chain when an edge at its front is undone', () => {
|
||||
const f = fixture();
|
||||
const nodes = Array.from({ length: 3 }, () => f.edit.addNode('FirstNotEmpty'));
|
||||
f.edit.wire([
|
||||
[nodes[0]!, 'value', nodes[1]!, 'a'],
|
||||
[nodes[0]!, 'value', nodes[1]!, 'b'],
|
||||
[nodes[1]!, 'value', nodes[2]!, 'a'],
|
||||
[nodes[1]!, 'value', nodes[2]!, 'b'],
|
||||
[f.edit.addNode('Number'), 'value', nodes[0]!, 'a'],
|
||||
[f.edit.addNode('Number'), 'value', nodes[0]!, 'b'],
|
||||
]);
|
||||
expect(nodes.map((n) => typeOf(f.behavior, n, 'value', f))).toEqual(['number', 'number', 'number']);
|
||||
|
||||
f.stack.undo();
|
||||
expect(nodes.map((n) => typeOf(f.behavior, n, 'value', f))).toEqual(['any', 'any', 'any']);
|
||||
|
||||
f.stack.redo();
|
||||
expect(nodes.map((n) => typeOf(f.behavior, n, 'value', f))).toEqual(['number', 'number', 'number']);
|
||||
});
|
||||
|
||||
it('ends a circle of two with any instead of asking forever', () => {
|
||||
const f = fixture();
|
||||
const [first, second] = circle(f);
|
||||
expect(typeOf(f.behavior, first, 'value', f)).toBe('any');
|
||||
expect(typeOf(f.behavior, second, 'value', f)).toBe('any');
|
||||
});
|
||||
|
||||
it('ends a node feeding itself with any', () => {
|
||||
const f = fixture();
|
||||
const node = f.edit.addNode('FirstNotEmpty');
|
||||
f.edit.connect(f.edit.addNode('Number'), 'value', node, 'b');
|
||||
// No command lays this edge, a file written by hand can still carry it.
|
||||
f.behavior.graph.edges.push({
|
||||
id: 'loop',
|
||||
from: { node: node.id, port: 'value' },
|
||||
to: { node: node.id, port: 'a' },
|
||||
});
|
||||
expect(typeOf(f.behavior, node, 'value', f)).toBe('any');
|
||||
});
|
||||
|
||||
it('is any behind a circle as well', () => {
|
||||
const f = fixture();
|
||||
const [, second] = circle(f);
|
||||
const behind = f.edit.addNode('FirstNotEmpty');
|
||||
f.edit.wire([
|
||||
[second, 'value', behind, 'a'],
|
||||
[f.edit.addNode('Number'), 'value', behind, 'b'],
|
||||
]);
|
||||
expect(typeOf(f.behavior, behind, 'value', f)).toBe('any');
|
||||
});
|
||||
|
||||
it('makes an edge further down the chain invalid, and leaves it in place', () => {
|
||||
const f = fixture();
|
||||
const first = f.edit.addNode('FirstNotEmpty');
|
||||
const second = f.edit.addNode('FirstNotEmpty');
|
||||
const sub = f.edit.addNode('Substring');
|
||||
f.edit.wire([
|
||||
[second, 'value', sub, 'text'],
|
||||
[first, 'value', second, 'a'],
|
||||
[first, 'value', second, 'b'],
|
||||
]);
|
||||
const lookup = () => graphLookup(f.project);
|
||||
const out = () =>
|
||||
portTypeOf(f.behavior.graph, { nodeId: second.id, port: 'value', isOutput: true }, lookup())!;
|
||||
const inp = () =>
|
||||
portTypeOf(f.behavior.graph, { nodeId: sub.id, port: 'text', isOutput: false }, lookup())!;
|
||||
expect(portsCompatible(out(), inp())).toBe(true);
|
||||
|
||||
f.edit.wire([
|
||||
[f.edit.addNode('Number'), 'value', first, 'a'],
|
||||
[f.edit.addNode('Number'), 'value', first, 'b'],
|
||||
]);
|
||||
expect(portsCompatible(out(), inp())).toBe(false);
|
||||
expect(f.behavior.graph.edges.filter((e) => e.to.node === sub.id)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('asks a node feeding both inputs of the next only once, however long the chain', () => {
|
||||
const f = fixture();
|
||||
// Asked once per path, the last of 40 would be asked 2^40 times and this test would never end.
|
||||
const nodes = chain(f, 40, 'Number', 'Select');
|
||||
expect(typeOf(f.behavior, nodes[39]!, 'value', f)).toBe('number');
|
||||
});
|
||||
|
||||
it('resolves every node of a chain of 50 well within a frame', () => {
|
||||
const f = fixture();
|
||||
const nodes = chain(f, 50, 'Number');
|
||||
const lookup = graphLookup(f.project);
|
||||
const all = () => {
|
||||
for (const node of f.behavior.graph.nodes)
|
||||
drawnDef(node, f.behavior.graph, resolveDef(node, f.behavior.graph, lookup)!, lookup);
|
||||
};
|
||||
all();
|
||||
const start = performance.now();
|
||||
for (let i = 0; i < 20; i++) all();
|
||||
const each = (performance.now() - start) / 20;
|
||||
expect(typeOf(f.behavior, nodes[49]!, 'value', f)).toBe('number');
|
||||
// What the canvas does on every change to the project. Measured at 10 ms on the slowest host,
|
||||
// against 2 ms before chains; the bound only has to catch a walk that grows with every path.
|
||||
expect(each).toBeLessThan(200);
|
||||
});
|
||||
});
|
||||
|
||||
/** As the canvas draws it: `drawnDef` is the only place a pin colour is decided. */
|
||||
const inputPin = (behavior: BehaviorAsset, node: GraphNode, key: string, f: ProjectFixture): PortDef => {
|
||||
const lookup = graphLookup(f.project);
|
||||
@@ -223,11 +386,25 @@ describe('an any input painted as what flows into it', () => {
|
||||
[f.edit.addNode('Number'), 'value', first, 'b'],
|
||||
[first, 'value', second, 'a'],
|
||||
]);
|
||||
// The edge is drawn in number, so the pin it ends on is too – the type is not passed on.
|
||||
// The edge is drawn in number, so the pin it ends on is too. Its other input is still open.
|
||||
expect(inputPin(f.behavior, second, 'a', f).paintType).toBe('number');
|
||||
expect(typeOf(f.behavior, second, 'value', f)).toBe('any');
|
||||
});
|
||||
|
||||
it('follows a type handed on through a chain, two nodes back', () => {
|
||||
const f = fixture();
|
||||
const nodes = chain(f, 3, 'Number');
|
||||
expect(inputPin(f.behavior, nodes[2]!, 'a', f).paintType).toBe('number');
|
||||
expect(inputPin(f.behavior, nodes[2]!, 'b', f).paintType).toBe('number');
|
||||
});
|
||||
|
||||
it('stays grey on every pin of a circle', () => {
|
||||
const f = fixture();
|
||||
const [first, second] = circle(f);
|
||||
expect(inputPin(f.behavior, first, 'a', f).paintType).toBeUndefined();
|
||||
expect(inputPin(f.behavior, second, 'a', f).paintType).toBeUndefined();
|
||||
});
|
||||
|
||||
it('leaves an input with a type of its own alone', () => {
|
||||
const f = fixture();
|
||||
const sub = f.edit.addNode('Substring');
|
||||
|
||||
@@ -1050,6 +1050,41 @@ export async function graph(t) {
|
||||
JSON.stringify({ pinColors, wanted }),
|
||||
);
|
||||
await shot('20-input-pin-colours');
|
||||
|
||||
// A second node of the kind behind the first (#210): the type is handed on through the chain,
|
||||
// and taken back all along it once the front of the chain disagrees again.
|
||||
await mutate(`
|
||||
__gm.state.project.assets
|
||||
.find((a) => a.id === 'picky')
|
||||
.graph.nodes.push({ id: 'p-next', type: 'FirstNotEmpty', x: 300, y: 330, params: {} });
|
||||
`);
|
||||
const nextTypes = async () => json(`__gm.graph.portTypes('p-next')`);
|
||||
await wire('p-pick', 'value', 'p-next', 'a');
|
||||
await wire('p-pick', 'value', 'p-next', 'b');
|
||||
const chainOpen = await nextTypes();
|
||||
await wire('p-a', 'value', 'p-pick', 'a');
|
||||
const chainTyped = await nextTypes();
|
||||
check(
|
||||
'a second node of the kind behind the first takes the type the first one agreed on',
|
||||
chainOpen.outputs.value === 'any' &&
|
||||
(await pickTypes()).outputs.value === 'number' &&
|
||||
chainTyped.outputs.value === 'number' &&
|
||||
chainTyped.inputs.a === 'any' &&
|
||||
chainTyped.paint.a === 'number' &&
|
||||
chainTyped.paint.b === 'number',
|
||||
JSON.stringify({ chainOpen, chainTyped }),
|
||||
);
|
||||
await shot('20b-shared-type-chain');
|
||||
await wire('p-text', 'value', 'p-pick', 'a');
|
||||
const chainMixed = await nextTypes();
|
||||
check(
|
||||
'a text plugged into the first opens the whole chain up again',
|
||||
(await pickTypes()).outputs.value === 'any' &&
|
||||
chainMixed.outputs.value === 'any' &&
|
||||
chainMixed.paint.a === 'any' &&
|
||||
chainMixed.paint.b === 'any',
|
||||
JSON.stringify(chainMixed),
|
||||
);
|
||||
await mutate(`
|
||||
const p = __gm.state.project;
|
||||
p.assets = p.assets.filter((a) => a.id !== 'picky');
|
||||
|
||||
Reference in new issue
Block a user