Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed

- Studio: metadata pack schemas are compiled with one shared Ajv instance instead of one instance per schema (128 distinct schemas: about 1.4 s to 60 ms). Edited schemas are still recompiled.
- Studio: `@rendis/statepro-studio-react` no longer depends on `fast-deep-equal`.

### Fixed

- Runtime: unlocking the machine mutex when it is not locked panics, like `sync.Mutex`, instead of blocking forever.
- Studio: an edit whose patch only sets absent fields to `undefined` no longer records an undo step that changes nothing.

## [3.4.0] - 2026-10-05

### Added
Expand Down
9 changes: 8 additions & 1 deletion experimental/runtime_control.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,14 @@ func (m *contextMutex) init() {
m.once.Do(func() { m.token = make(chan struct{}, 1); m.token <- struct{}{} })
}
func (m *contextMutex) Lock() { m.init(); <-m.token }
func (m *contextMutex) Unlock() { m.token <- struct{}{} }
func (m *contextMutex) Unlock() {
select {
case m.token <- struct{}{}:
default:
// Match sync.Mutex: unlocking an unlocked mutex is a bug, not a deadlock.
panic("statepro: unlock of unlocked contextMutex")
}
}
func (m *contextMutex) LockContext(ctx context.Context) error {
if ctx == nil {
return fmt.Errorf("context must not be nil")
Expand Down
12 changes: 12 additions & 0 deletions experimental/runtime_control_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -468,3 +468,15 @@ func TestRuntime_TrackingLimitadoPreservaRollback(t *testing.T) {
t.Fatalf("historial sin limite: %v", u.tracking)
}
}

func TestRuntime_UnlockSinLockEntraEnPanicEnVezDeBloquear(t *testing.T) {
var mtx contextMutex
mtx.Lock()
mtx.Unlock()
defer func() {
if recover() == nil {
t.Fatal("se esperaba panic al desbloquear un mutex libre")
}
}()
mtx.Unlock()
}
3 changes: 1 addition & 2 deletions studio/packages/editor-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,7 @@
"ajv": "^8.17.1",
"elkjs": "^0.11.0",
"lucide-react": "^0.468.0",
"react-zoom-pan-pinch": "^3.7.0",
"fast-deep-equal": "^3.1.3"
"react-zoom-pan-pinch": "^3.7.0"
},
"devDependencies": {
"@stryker-mutator/core": "^10.0.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,24 @@ describe("editorHistoryReducer", () => {
expect(domainNext.present.isDirtyFromImport).toBe(true);
});

it("un patch con propiedades undefined que no cambia el JSON no crea un paso de historial", () => {
const initial = createInitialEditorHistoryState(createInitialEditorState());
const node = initial.present.nodes[0];
const next = editorHistoryReducer(initial, {
type: "apply-editor-action",
mode: "record",
action: { type: "update-node-data", payload: { nodeId: node.id, patch: { notAField: undefined } } },
});
expect(next.past).toHaveLength(0);

const changed = editorHistoryReducer(initial, {
type: "apply-editor-action",
mode: "record",
action: { type: "update-node-data", payload: { nodeId: node.id, patch: { notAField: "value" } } },
});
expect(changed.past).toHaveLength(1);
});

it("record apila en past y limpia future", () => {
const initial = createInitialEditorHistoryState(createInitialEditorState());
const first = applyMachineId(initial, "machine-a", "record");
Expand Down
1 change: 0 additions & 1 deletion studio/packages/editor-core/src/i18n/messages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -375,7 +375,6 @@ export const STUDIO_I18N_EN = {
"issue.initialUnknownUniverse": "Unknown universe '{{universeId}}' in initials",
"issue.initialUnknownReality": "Unknown reality '{{realityId}}' for universe '{{universeId}}' in initials",
"issue.conditionMissingSrc": "Condition must have src",
"issue.duplicatedTransitionCondition": "Condition '{{src}}' is duplicated in transition conditions.",
"issue.machineIdInvalid": "Machine id has invalid format",
"issue.machineCanonicalInvalid": "Machine canonicalName has invalid format",
"issue.universeKeyInvalid": "Universe key has invalid format",
Expand Down
1 change: 0 additions & 1 deletion studio/packages/editor-core/src/i18n/messages/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -377,7 +377,6 @@ export const STUDIO_I18N_ES: Record<StudioEnMessageKey, string> = {
"issue.initialUnknownUniverse": "Universo desconocido '{{universeId}}' en initials",
"issue.initialUnknownReality": "Realidad desconocida '{{realityId}}' para universo '{{universeId}}' en initials",
"issue.conditionMissingSrc": "Condition debe tener src",
"issue.duplicatedTransitionCondition": "La condicion '{{src}}' esta duplicada en conditions de la transicion.",
"issue.machineIdInvalid": "Formato invalido para machine id",
"issue.machineCanonicalInvalid": "Formato invalido para machine canonicalName",
"issue.universeKeyInvalid": "Formato invalido para universe key",
Expand Down
29 changes: 27 additions & 2 deletions studio/packages/editor-core/src/state/editorHistoryReducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import type {
} from "../types";
import { editorReducer, type EditorAction } from "./editorReducer";
import { takeHistoryOwnership } from "./historyOwnership";
import deepEqual from "fast-deep-equal";

export const HISTORY_LIMIT = 100;
export const COALESCE_WINDOW_MS = 600;
Expand Down Expand Up @@ -68,6 +67,32 @@ const toSnapshot = (state: EditorState): EditorHistorySnapshot => ({
isDirtyFromImport: state.isDirtyFromImport,
});

const definedKeys = (value: Record<string, unknown>): string[] =>
Object.keys(value).filter((key) => value[key] !== undefined);

// Snapshot data is JSON. Compare it with JSON semantics: a property set to
// undefined equals a missing one, so a patch like { field: undefined } on an
// object without that field does not record an undo step that changes nothing.
const jsonEqual = (left: unknown, right: unknown): boolean => {
if (Object.is(left, right)) {
return true;
}
if (typeof left !== "object" || typeof right !== "object" || left === null || right === null) {
return false;
}
if (Array.isArray(left) || Array.isArray(right)) {
return Array.isArray(left)
&& Array.isArray(right)
&& left.length === right.length
&& left.every((item, index) => jsonEqual(item, right[index]));
}
const leftRecord = left as Record<string, unknown>;
const rightRecord = right as Record<string, unknown>;
const leftKeys = definedKeys(leftRecord);
return leftKeys.length === definedKeys(rightRecord).length
&& leftKeys.every((key) => jsonEqual(leftRecord[key], rightRecord[key]));
};

const snapshotsEqual = (
left: EditorHistorySnapshot,
right: EditorHistorySnapshot,
Expand All @@ -78,7 +103,7 @@ const snapshotsEqual = (
// its own keys would miss a present imported model that must be cleared.
const keys = new Set([...Object.keys(left), ...Object.keys(right)]);
return (Array.from(keys) as Array<keyof EditorHistorySnapshot>).every(
(key) => Object.is(left[key], right[key]) || deepEqual(left[key], right[key]),
(key) => jsonEqual(left[key], right[key]),
);
};

Expand Down
15 changes: 12 additions & 3 deletions studio/packages/editor-core/src/utils/metadataPacks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,16 @@ export const METADATA_PACK_MACHINE_ENTITY_REF = "machine";

const METADATA_SCOPES: MetadataScope[] = ["machine", "universe", "reality", "transition"];

const createValidatorCompiler = () => new Ajv2020({
// One shared compiler instead of a new Ajv instance per schema. addUsedSchema:
// false keeps compiled schemas out of the $id registry, so edited schemas can
// reuse a previous $id; validateBindingWithPack also drops each schema from
// Ajv's identity cache, so edits made in place recompile and memory stays
// bounded by validatorCache.
const validatorCompiler = new Ajv2020({
allErrors: true,
strict: false,
allowUnionTypes: true,
addUsedSchema: false,
});

const VALIDATOR_CACHE_LIMIT = 128;
Expand Down Expand Up @@ -698,8 +704,11 @@ export const validateBindingWithPack = (
const cacheKey = JSON.stringify(pack.schema);
let validator = validatorCache.get(cacheKey);
if (!validator) {
// Isolate $id registrations so edited schemas can reuse their previous ID.
validator = createValidatorCompiler().compile(pack.schema as object);
try {
validator = validatorCompiler.compile(pack.schema as object);
} finally {
validatorCompiler.removeSchema(pack.schema as object);
}
} else {
validatorCache.delete(cacheKey);
}
Expand Down
3 changes: 0 additions & 3 deletions studio/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading