From 35f6d8af5659d982acb9c6af9ff328893d81c865 Mon Sep 17 00:00:00 2001 From: Bastian Kruck Date: Mon, 8 Dec 2025 20:47:25 +0100 Subject: [PATCH 1/5] feat(vue-model-api): useReplicatedModels allows multiple linked models --- vue-model-api/src/index.ts | 2 +- vue-model-api/src/useReplicatedModel.test.ts | 164 +++------------- vue-model-api/src/useReplicatedModels.test.ts | 180 ++++++++++++++++++ ...licatedModel.ts => useReplicatedModels.ts} | 88 +++++++-- 4 files changed, 277 insertions(+), 157 deletions(-) create mode 100644 vue-model-api/src/useReplicatedModels.test.ts rename vue-model-api/src/{useReplicatedModel.ts => useReplicatedModels.ts} (62%) diff --git a/vue-model-api/src/index.ts b/vue-model-api/src/index.ts index 2e1eaa65c8..b4ff7df7f9 100644 --- a/vue-model-api/src/index.ts +++ b/vue-model-api/src/index.ts @@ -1,4 +1,4 @@ export { useModelsFromJson } from "./useModelsFromJson"; export { useModelClient } from "./useModelClient"; -export { useReplicatedModel } from "./useReplicatedModel"; +export { useReplicatedModels, useReplicatedModel } from "./useReplicatedModels"; export { useReadonlyVersion } from "./useReadonlyVersion"; diff --git a/vue-model-api/src/useReplicatedModel.test.ts b/vue-model-api/src/useReplicatedModel.test.ts index 2e458b5bad..0ae9e86cfb 100644 --- a/vue-model-api/src/useReplicatedModel.test.ts +++ b/vue-model-api/src/useReplicatedModel.test.ts @@ -1,67 +1,55 @@ import { org } from "@modelix/model-client"; -import type { INodeJS } from "@modelix/ts-model-api"; import { toRoleJS } from "@modelix/ts-model-api"; -import { watchEffect, type Ref, ref } from "vue"; +import { watchEffect } from "vue"; import { useModelClient } from "./useModelClient"; -import { useReplicatedModel } from "./useReplicatedModel"; +import { useReplicatedModel } from "./useReplicatedModels"; import IdSchemeJS = org.modelix.model.client2.IdSchemeJS; -type BranchJS = org.modelix.model.client2.MutableModelTreeJs; -type ReplicatedModelJS = org.modelix.model.client2.ReplicatedModelJS; type ClientJS = org.modelix.model.client2.ClientJS; +type ReplicatedModelJS = org.modelix.model.client2.ReplicatedModelJS; const { loadModelsFromJson } = org.modelix.model.client2; -class SuccessfulBranchJS { - public rootNode: INodeJS; - - constructor(branchId: string) { - const root = { - root: {}, - }; - - this.rootNode = loadModelsFromJson([JSON.stringify(root)]); - this.rootNode.setPropertyValue(toRoleJS("branchId"), branchId); - } - - addListener = jest.fn(); -} - -class SuccessfulReplicatedModelJS { - private branch: BranchJS; - constructor(branchId: string) { - this.branch = new SuccessfulBranchJS(branchId) as unknown as BranchJS; - } - - getBranch() { - return this.branch; - } - dispose = jest.fn(); -} +import ReplicatedModelParameters = org.modelix.model.client2.ReplicatedModelParameters; -test("test branch connects", (done) => { +test("test wrapper backwards compatibility", (done) => { class SuccessfulClientJS { - startReplicatedModel( - _repositoryId: string, - branchId: string, + startReplicatedModels( + parameters: ReplicatedModelParameters[], ): Promise { - return Promise.resolve( - new SuccessfulReplicatedModelJS( - branchId, - ) as unknown as ReplicatedModelJS, - ); + // Mock implementation that returns a dummy object with a branch + const branchId = parameters[0].branchId; + const rootNode = loadModelsFromJson([JSON.stringify({ root: {} })]); + rootNode.setPropertyValue(toRoleJS("branchId"), branchId); + + const branch = { + rootNode, + addListener: jest.fn(), + removeListener: jest.fn(), + resolveNode: jest.fn(), + }; + + const replicatedModel = { + getBranch: () => branch, + dispose: jest.fn(), + getCurrentVersionInformation: jest.fn(), + } as unknown as ReplicatedModelJS; + + return Promise.resolve(replicatedModel); } } const { client } = useModelClient("anURL", () => Promise.resolve(new SuccessfulClientJS() as unknown as ClientJS), ); + const { rootNode, replicatedModel } = useReplicatedModel( client, "aRepository", "aBranch", IdSchemeJS.MODELIX, ); + watchEffect(() => { if (rootNode.value !== null && replicatedModel.value !== null) { expect(rootNode.value.getPropertyValue(toRoleJS("branchId"))).toBe( @@ -71,99 +59,3 @@ test("test branch connects", (done) => { } }); }); - -test("test branch connection error is exposed", (done) => { - class FailingClientJS { - startReplicatedModel( - _repositoryId: string, - _branchId: string, - ): Promise { - return Promise.reject("Could not connect branch."); - } - } - - const { client } = useModelClient("anURL", () => - Promise.resolve(new FailingClientJS() as unknown as ClientJS), - ); - - const { error } = useReplicatedModel( - client, - "aRepository", - "aBranch", - IdSchemeJS.MODELIX, - ); - - watchEffect(() => { - if (error.value !== null) { - expect(error.value).toBe("Could not connect branch."); - done(); - } - }); -}); - -describe("does not start model", () => { - const startReplicatedModel = jest.fn((repositoryId, branchId) => - Promise.resolve( - new SuccessfulReplicatedModelJS(branchId) as unknown as ReplicatedModelJS, - ), - ); - - let client: Ref; - class MockClientJS { - startReplicatedModel( - _repositoryId: string, - _branchId: string, - ): Promise { - return startReplicatedModel(_repositoryId, _branchId); - } - } - - beforeEach(() => { - jest.clearAllMocks(); - client = useModelClient("anURL", () => - Promise.resolve(new MockClientJS() as unknown as ClientJS), - ).client; - }); - - test("if client is undefined", () => { - useReplicatedModel(undefined, "aRepository", "aBranch", IdSchemeJS.MODELIX); - expect(startReplicatedModel).not.toHaveBeenCalled(); - }); - - test("if repositoryId is undefined", () => { - useReplicatedModel(client, undefined, "aBranch", IdSchemeJS.MODELIX); - expect(startReplicatedModel).not.toHaveBeenCalled(); - }); - - test("if branchId is undefined", () => { - useReplicatedModel(client, "aRepository", undefined, IdSchemeJS.MODELIX); - expect(startReplicatedModel).not.toHaveBeenCalled(); - }); - - test("if idScheme is undefined", () => { - useReplicatedModel(client, "aRepository", "aBranch", undefined); - expect(startReplicatedModel).not.toHaveBeenCalled(); - }); - - test("if repositoryId switches to another value", async () => { - const repositoryId = ref("aRepository"); - useReplicatedModel(client, repositoryId, "aBranch", IdSchemeJS.MODELIX); - expect(startReplicatedModel).toHaveBeenCalled(); - - startReplicatedModel.mockClear(); - repositoryId.value = "aNewValue"; - await new Promise(process.nextTick); - expect(startReplicatedModel).toHaveBeenCalled(); - }); - - test("if repositoryId switches to undefined", async () => { - const repositoryId = ref("aRepository"); - useReplicatedModel(client, repositoryId, "aBranch", IdSchemeJS.MODELIX); - expect(startReplicatedModel).toHaveBeenCalled(); - - startReplicatedModel.mockClear(); - repositoryId.value = undefined; - await new Promise(process.nextTick); - expect(startReplicatedModel).not.toHaveBeenCalled(); - }); -}); diff --git a/vue-model-api/src/useReplicatedModels.test.ts b/vue-model-api/src/useReplicatedModels.test.ts new file mode 100644 index 0000000000..c0725c2952 --- /dev/null +++ b/vue-model-api/src/useReplicatedModels.test.ts @@ -0,0 +1,180 @@ +import { org } from "@modelix/model-client"; +import type { INodeJS } from "@modelix/ts-model-api"; +import { toRoleJS } from "@modelix/ts-model-api"; +import { watchEffect, type Ref, ref } from "vue"; +import { useModelClient } from "./useModelClient"; +import { useReplicatedModels } from "./useReplicatedModels"; +import IdSchemeJS = org.modelix.model.client2.IdSchemeJS; +import ReplicatedModelParameters = org.modelix.model.client2.ReplicatedModelParameters; + +type BranchJS = org.modelix.model.client2.MutableModelTreeJs; +type ReplicatedModelJS = org.modelix.model.client2.ReplicatedModelJS; +type ClientJS = org.modelix.model.client2.ClientJS; + +const { loadModelsFromJson } = org.modelix.model.client2; + +class SuccessfulBranchJS { + public rootNode: INodeJS; + + constructor(branchId: string) { + const root = { + root: {}, + }; + + this.rootNode = loadModelsFromJson([JSON.stringify(root)]); + this.rootNode.setPropertyValue(toRoleJS("branchId"), branchId); + } + + addListener = jest.fn(); +} + +class SuccessfulReplicatedModelJS { + private branch: BranchJS; + constructor(branchId: string) { + this.branch = new SuccessfulBranchJS(branchId) as unknown as BranchJS; + } + + getBranch() { + return this.branch; + } + dispose = jest.fn(); +} + +test("test branch connects", (done) => { + class SuccessfulClientJS { + startReplicatedModels( + parameters: ReplicatedModelParameters[], + ): Promise { + // For this test, we assume only one model is requested and we use its branchId + const branchId = parameters[0].branchId; + return Promise.resolve( + new SuccessfulReplicatedModelJS( + branchId, + ) as unknown as ReplicatedModelJS, + ); + } + } + + const { client } = useModelClient("anURL", () => + Promise.resolve(new SuccessfulClientJS() as unknown as ClientJS), + ); + const { rootNode, replicatedModel } = useReplicatedModels(client, [ + new ReplicatedModelParameters("aRepository", "aBranch", IdSchemeJS.MODELIX), + ]); + watchEffect(() => { + if (rootNode.value !== null && replicatedModel.value !== null) { + expect(rootNode.value.getPropertyValue(toRoleJS("branchId"))).toBe( + "aBranch", + ); + done(); + } + }); +}); + +test("test branch connection error is exposed", (done) => { + class FailingClientJS { + startReplicatedModels( + _parameters: ReplicatedModelParameters[], + ): Promise { + return Promise.reject("Could not connect branch."); + } + } + + const { client } = useModelClient("anURL", () => + Promise.resolve(new FailingClientJS() as unknown as ClientJS), + ); + + const { error } = useReplicatedModels(client, [ + new ReplicatedModelParameters("aRepository", "aBranch", IdSchemeJS.MODELIX), + ]); + + watchEffect(() => { + if (error.value !== null) { + expect(error.value).toBe("Could not connect branch."); + done(); + } + }); +}); + +describe("does not start model", () => { + const startReplicatedModels = jest.fn((parameters) => { + // Return a dummy replicated model + const branchId = parameters[0]?.branchId ?? "defaultBranch"; + return Promise.resolve( + new SuccessfulReplicatedModelJS(branchId) as unknown as ReplicatedModelJS, + ); + }); + + let client: Ref; + class MockClientJS { + startReplicatedModels( + parameters: ReplicatedModelParameters[], + ): Promise { + return startReplicatedModels(parameters); + } + } + + beforeEach(() => { + jest.clearAllMocks(); + client = useModelClient("anURL", () => + Promise.resolve(new MockClientJS() as unknown as ClientJS), + ).client; + }); + + test("if client is undefined", () => { + useReplicatedModels(undefined, [ + new ReplicatedModelParameters( + "aRepository", + "aBranch", + IdSchemeJS.MODELIX, + ), + ]); + expect(startReplicatedModels).not.toHaveBeenCalled(); + }); + + test("if models is undefined", () => { + useReplicatedModels(client, undefined); + expect(startReplicatedModels).not.toHaveBeenCalled(); + }); + + test("if models switches to another value", async () => { + const models = ref([ + new ReplicatedModelParameters( + "aRepository", + "aBranch", + IdSchemeJS.MODELIX, + ), + ]); + useReplicatedModels(client, models); + expect(startReplicatedModels).toHaveBeenCalled(); + + startReplicatedModels.mockClear(); + models.value = [ + new ReplicatedModelParameters( + "aNewRepository", + "aNewBranch", + IdSchemeJS.MODELIX, + ), + ]; + await new Promise(process.nextTick); + expect(startReplicatedModels).toHaveBeenCalled(); + }); + + test("if models switches to undefined", async () => { + const models = ref([ + new ReplicatedModelParameters( + "aRepository", + "aBranch", + IdSchemeJS.MODELIX, + ), + ]); + useReplicatedModels(client, models); + expect(startReplicatedModels).toHaveBeenCalled(); + + startReplicatedModels.mockClear(); + models.value = undefined; + await new Promise(process.nextTick); + // It should not call startReplicatedModels, but it might trigger dispose() + expect(startReplicatedModels).not.toHaveBeenCalled(); + }); +}); diff --git a/vue-model-api/src/useReplicatedModel.ts b/vue-model-api/src/useReplicatedModels.ts similarity index 62% rename from vue-model-api/src/useReplicatedModel.ts rename to vue-model-api/src/useReplicatedModels.ts index 4c42060578..959d584433 100644 --- a/vue-model-api/src/useReplicatedModel.ts +++ b/vue-model-api/src/useReplicatedModels.ts @@ -1,8 +1,8 @@ -import type { org } from "@modelix/model-client"; +import { org } from "@modelix/model-client"; import type { INodeJS } from "@modelix/ts-model-api"; import { useLastPromiseEffect } from "./internal/useLastPromiseEffect"; import type { MaybeRefOrGetter, Ref } from "vue"; -import { shallowRef, toValue } from "vue"; +import { shallowRef, toValue, computed } from "vue"; import type { ReactiveINodeJS } from "./internal/ReactiveINodeJS"; import { toReactiveINodeJS } from "./internal/ReactiveINodeJS"; import { Cache } from "./internal/Cache"; @@ -11,6 +11,8 @@ import { handleChange } from "./internal/handleChange"; type ClientJS = org.modelix.model.client2.ClientJS; type ReplicatedModelJS = org.modelix.model.client2.ReplicatedModelJS; type ChangeJS = org.modelix.model.client2.ChangeJS; +type ReplicatedModelParameters = + org.modelix.model.client2.ReplicatedModelParameters; function isDefined(value: T | null | undefined): value is T { return value !== null && value !== undefined; @@ -37,13 +39,9 @@ function isDefined(value: T | null | undefined): value is T { * @returns {() => void} values.dispose A function to manually dispose the root node. * @returns {Ref} values.error Reactive reference to a connection error. */ -export function useReplicatedModel( +export function useReplicatedModels( client: MaybeRefOrGetter, - repositoryId: MaybeRefOrGetter, - branchId: MaybeRefOrGetter, - idScheme: MaybeRefOrGetter< - org.modelix.model.client2.IdSchemeJS | null | undefined - >, + models: MaybeRefOrGetter, ): { replicatedModel: Ref; rootNode: Ref; @@ -67,28 +65,23 @@ export function useReplicatedModel( errorRef.value = null; }; - useLastPromiseEffect( + useLastPromiseEffect<{ + replicatedModel: ReplicatedModelJS; + cache: Cache; + }>( () => { dispose(); const clientValue = toValue(client); if (!isDefined(clientValue)) { return; } - const repositoryIdValue = toValue(repositoryId); - if (!isDefined(repositoryIdValue)) { - return; - } - const branchIdValue = toValue(branchId); - if (!isDefined(branchIdValue)) { - return; - } - const idSchemeValue = toValue(idScheme); - if (!isDefined(idSchemeValue)) { + const modelsValue = toValue(models); + if (!isDefined(modelsValue)) { return; } const cache = new Cache(); return clientValue - .startReplicatedModel(repositoryIdValue, branchIdValue, idSchemeValue) + .startReplicatedModels(modelsValue) .then((replicatedModel) => ({ replicatedModel, cache })); }, ( @@ -126,3 +119,58 @@ export function useReplicatedModel( error: errorRef, }; } + +/** + * Creates a replicated model for a given repository and branch. + * A replicated model exposes a branch that can be used to read and write model data. + * The written model data is automatically synced to the model server. + * Changed from the model server are automatically synced to the branch in the replicated model + * + * Also creates root node that uses Vues reactivity and can be used in Vue like a reactive object. + * Changes to model data trigger recalculation of computed properties or re-rendering of components using that data. + * + * Calling the returned dispose function stops syncing the root node to the underlying branch on the server. + * + * @param client - Reactive reference of a client to a model server. + * @param repositoryId - Reactive reference of a repositoryId on the model server. + * @param branchId - Reactive reference of a branchId in the repository of the model server. + * + * @returns {Object} values Wrapper around different returned values. + * @returns {Ref} values.rootNode Reactive reference to the replicated model for the specified branch. + * @returns {Ref} values.rootNode Reactive reference to the root node with Vue.js reactivity for the specified branch. + * @returns {() => void} values.dispose A function to manually dispose the root node. + * @returns {Ref} values.error Reactive reference to a connection error. + * + * @deprecated Use {@link useReplicatedModels} instead. + */ +export function useReplicatedModel( + client: MaybeRefOrGetter, + repositoryId: MaybeRefOrGetter, + branchId: MaybeRefOrGetter, + idScheme: MaybeRefOrGetter< + org.modelix.model.client2.IdSchemeJS | null | undefined + >, +): { + replicatedModel: Ref; + rootNode: Ref; + dispose: () => void; + error: Ref; +} { + const models = computed(() => { + const repositoryIdValue = toValue(repositoryId); + const branchIdValue = toValue(branchId); + const idSchemeValue = toValue(idScheme); + if (!repositoryIdValue || !branchIdValue || !idSchemeValue) { + return null; + } + return [ + new org.modelix.model.client2.ReplicatedModelParameters( + repositoryIdValue, + branchIdValue, + idSchemeValue, + ), + ]; + }); + + return useReplicatedModels(client, models); +} From 5727772bc971ff6b0e099e65279eedbdefae3e9f Mon Sep 17 00:00:00 2001 From: Bastian Kruck Date: Wed, 10 Dec 2025 00:49:46 +0100 Subject: [PATCH 2/5] chore: avoid class loading conflict in processIdeaSettings --- build.gradle.kts | 1 + 1 file changed, 1 insertion(+) diff --git a/build.gradle.kts b/build.gradle.kts index 7e5b8dbcd3..b1be3bd9ee 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -13,6 +13,7 @@ plugins { alias(libs.plugins.detekt) alias(libs.plugins.kotlinx.kover) alias(libs.plugins.npm.publish) apply false + id("org.jetbrains.gradle.plugin.idea-ext") version "1.1.9" apply false } group = "org.modelix" From fef53f75feb2bc8c90a7fa95ac6e8e7d9ebb785a Mon Sep 17 00:00:00 2001 From: Bastian Kruck Date: Wed, 10 Dec 2025 01:02:10 +0100 Subject: [PATCH 3/5] chore(model-server): avoid class reloading issue in DiffViewTest --- .../modelix/model/server/ModelServerTestUtil.kt | 14 ++++++++++++++ .../model/server/handlers/ui/DiffViewTest.kt | 4 ++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/model-server/src/test/kotlin/org/modelix/model/server/ModelServerTestUtil.kt b/model-server/src/test/kotlin/org/modelix/model/server/ModelServerTestUtil.kt index f203bbd87e..5a60bb8cae 100644 --- a/model-server/src/test/kotlin/org/modelix/model/server/ModelServerTestUtil.kt +++ b/model-server/src/test/kotlin/org/modelix/model/server/ModelServerTestUtil.kt @@ -46,3 +46,17 @@ fun runWithNettyServer( nettyServer.stop() } } + +fun runTestApplication(block: suspend ApplicationTestBuilder.() -> Unit) { + val previousDevMode = System.getProperty("io.ktor.development") + System.setProperty("io.ktor.development", "false") + try { + io.ktor.server.testing.testApplication(block) + } finally { + if (previousDevMode == null) { + System.clearProperty("io.ktor.development") + } else { + System.setProperty("io.ktor.development", previousDevMode) + } + } +} diff --git a/model-server/src/test/kotlin/org/modelix/model/server/handlers/ui/DiffViewTest.kt b/model-server/src/test/kotlin/org/modelix/model/server/handlers/ui/DiffViewTest.kt index 36fac58d11..a4ca479b24 100644 --- a/model-server/src/test/kotlin/org/modelix/model/server/handlers/ui/DiffViewTest.kt +++ b/model-server/src/test/kotlin/org/modelix/model/server/handlers/ui/DiffViewTest.kt @@ -17,7 +17,6 @@ import io.ktor.client.statement.HttpResponse import io.ktor.client.statement.bodyAsText import io.ktor.http.HttpStatusCode import io.ktor.server.testing.ApplicationTestBuilder -import io.ktor.server.testing.testApplication import io.mockk.clearAllMocks import io.mockk.coEvery import io.mockk.every @@ -42,6 +41,7 @@ import org.modelix.model.persistent.CPNode import org.modelix.model.persistent.CPNodeRef import org.modelix.model.server.handlers.RepositoriesManager import org.modelix.model.server.installDefaultServerPlugins +import org.modelix.model.server.runTestApplication import kotlin.test.BeforeTest import kotlin.test.Test @@ -308,7 +308,7 @@ class DiffViewTest { val v1 = createCLVersion { it } val v2 = createCLVersion(v1) { it } - private fun runDiffViewTest(block: suspend ApplicationTestBuilder.() -> Unit) = testApplication { + private fun runDiffViewTest(block: suspend ApplicationTestBuilder.() -> Unit) = runTestApplication { application { installDefaultServerPlugins() DiffView(repositoriesManager).init(this) From 39b8ea865ec1ff222c0b86c4ab5e89e0baa3c07a Mon Sep 17 00:00:00 2001 From: Bastian Kruck Date: Wed, 10 Dec 2025 17:04:31 +0100 Subject: [PATCH 4/5] chore: return rootNodes from useReplicatedModels --- vue-model-api/src/useReplicatedModel.test.ts | 1 + vue-model-api/src/useReplicatedModels.test.ts | 10 +++-- vue-model-api/src/useReplicatedModels.ts | 45 ++++++++++++------- 3 files changed, 36 insertions(+), 20 deletions(-) diff --git a/vue-model-api/src/useReplicatedModel.test.ts b/vue-model-api/src/useReplicatedModel.test.ts index 0ae9e86cfb..90a05bed0f 100644 --- a/vue-model-api/src/useReplicatedModel.test.ts +++ b/vue-model-api/src/useReplicatedModel.test.ts @@ -24,6 +24,7 @@ test("test wrapper backwards compatibility", (done) => { const branch = { rootNode, + getRootNodes: () => [rootNode], addListener: jest.fn(), removeListener: jest.fn(), resolveNode: jest.fn(), diff --git a/vue-model-api/src/useReplicatedModels.test.ts b/vue-model-api/src/useReplicatedModels.test.ts index c0725c2952..92f8842380 100644 --- a/vue-model-api/src/useReplicatedModels.test.ts +++ b/vue-model-api/src/useReplicatedModels.test.ts @@ -25,6 +25,10 @@ class SuccessfulBranchJS { this.rootNode.setPropertyValue(toRoleJS("branchId"), branchId); } + getRootNodes() { + return [this.rootNode]; + } + addListener = jest.fn(); } @@ -58,12 +62,12 @@ test("test branch connects", (done) => { const { client } = useModelClient("anURL", () => Promise.resolve(new SuccessfulClientJS() as unknown as ClientJS), ); - const { rootNode, replicatedModel } = useReplicatedModels(client, [ + const { rootNodes, replicatedModel } = useReplicatedModels(client, [ new ReplicatedModelParameters("aRepository", "aBranch", IdSchemeJS.MODELIX), ]); watchEffect(() => { - if (rootNode.value !== null && replicatedModel.value !== null) { - expect(rootNode.value.getPropertyValue(toRoleJS("branchId"))).toBe( + if (rootNodes.value.length > 0 && replicatedModel.value !== null) { + expect(rootNodes.value[0].getPropertyValue(toRoleJS("branchId"))).toBe( "aBranch", ); done(); diff --git a/vue-model-api/src/useReplicatedModels.ts b/vue-model-api/src/useReplicatedModels.ts index 959d584433..137174b35b 100644 --- a/vue-model-api/src/useReplicatedModels.ts +++ b/vue-model-api/src/useReplicatedModels.ts @@ -19,24 +19,23 @@ function isDefined(value: T | null | undefined): value is T { } /** - * Creates a replicated model for a given repository and branch. + * Creates replicated models for the given repositories and branches. * A replicated model exposes a branch that can be used to read and write model data. * The written model data is automatically synced to the model server. - * Changed from the model server are automatically synced to the branch in the replicated model + * Changes from the model server are automatically synced to the branch in the replicated model. * - * Also creates root node that uses Vues reactivity and can be used in Vue like a reactive object. + * Also creates root nodes that use Vue's reactivity and can be used in Vue like reactive objects. * Changes to model data trigger recalculation of computed properties or re-rendering of components using that data. * - * Calling the returned dispose function stops syncing the root node to the underlying branch on the server. + * Calling the returned dispose function stops syncing the root nodes to the underlying branches on the server. * * @param client - Reactive reference of a client to a model server. - * @param repositoryId - Reactive reference of a repositoryId on the model server. - * @param branchId - Reactive reference of a branchId in the repository of the model server. + * @param models - Reactive reference to an array of ReplicatedModelParameters. * * @returns {Object} values Wrapper around different returned values. - * @returns {Ref} values.rootNode Reactive reference to the replicated model for the specified branch. - * @returns {Ref} values.rootNode Reactive reference to the root node with Vue.js reactivity for the specified branch. - * @returns {() => void} values.dispose A function to manually dispose the root node. + * @returns {Ref} values.replicatedModel Reactive reference to the replicated model for the specified branches. + * @returns {Ref} values.rootNodes Reactive reference to an array of root nodes with Vue.js reactivity for the specified branches. + * @returns {() => void} values.dispose A function to manually dispose the root nodes. * @returns {Ref} values.error Reactive reference to a connection error. */ export function useReplicatedModels( @@ -44,14 +43,14 @@ export function useReplicatedModels( models: MaybeRefOrGetter, ): { replicatedModel: Ref; - rootNode: Ref; + rootNodes: Ref; dispose: () => void; error: Ref; } { // Use `replicatedModel` to access the replicated model without tracking overhead of Vue.js. let replicatedModel: ReplicatedModelJS | null = null; const replicatedModelRef: Ref = shallowRef(null); - const rootNodeRef: Ref = shallowRef(null); + const rootNodesRef: Ref = shallowRef([]); const errorRef: Ref = shallowRef(null); const dispose = () => { @@ -61,7 +60,7 @@ export function useReplicatedModels( replicatedModel.dispose(); } replicatedModelRef.value = null; - rootNodeRef.value = null; + rootNodesRef.value = []; errorRef.value = null; }; @@ -97,10 +96,12 @@ export function useReplicatedModels( } handleChange(change, cache); }); - const unreactiveRootNode = branch.rootNode; - const reactiveRootNode = toReactiveINodeJS(unreactiveRootNode, cache); + const unreactiveRootNodes = branch.getRootNodes(); + const reactiveRootNodes = unreactiveRootNodes.map((node) => + toReactiveINodeJS(node, cache), + ); replicatedModelRef.value = replicatedModel; - rootNodeRef.value = reactiveRootNode; + rootNodesRef.value = reactiveRootNodes; } else { connectedReplicatedModel.dispose(); } @@ -114,7 +115,7 @@ export function useReplicatedModels( return { replicatedModel: replicatedModelRef, - rootNode: rootNodeRef, + rootNodes: rootNodesRef, dispose, error: errorRef, }; @@ -172,5 +173,15 @@ export function useReplicatedModel( ]; }); - return useReplicatedModels(client, models); + const result = useReplicatedModels(client, models); + + // Extract the single root node from the array for backward compatibility + const rootNode = computed(() => result.rootNodes.value[0] ?? null); + + return { + replicatedModel: result.replicatedModel, + rootNode: rootNode, + dispose: result.dispose, + error: result.error, + }; } From 8d26c35bd7074a7cc4384dac4987c30d4c48e181 Mon Sep 17 00:00:00 2001 From: Bastian Kruck Date: Fri, 12 Dec 2025 15:50:07 +0100 Subject: [PATCH 5/5] chore(vue-model-api): restore old useReplicatedModel implementation as a fallback for now --- vue-model-api/src/index.ts | 3 +- vue-model-api/src/useReplicatedModel.ts | 128 +++++++++++++++++++++++ vue-model-api/src/useReplicatedModels.ts | 69 +----------- 3 files changed, 132 insertions(+), 68 deletions(-) create mode 100644 vue-model-api/src/useReplicatedModel.ts diff --git a/vue-model-api/src/index.ts b/vue-model-api/src/index.ts index b4ff7df7f9..1078102bd1 100644 --- a/vue-model-api/src/index.ts +++ b/vue-model-api/src/index.ts @@ -1,4 +1,5 @@ export { useModelsFromJson } from "./useModelsFromJson"; export { useModelClient } from "./useModelClient"; -export { useReplicatedModels, useReplicatedModel } from "./useReplicatedModels"; +export { useReplicatedModels } from "./useReplicatedModels"; +export { useReplicatedModel } from "./useReplicatedModel"; export { useReadonlyVersion } from "./useReadonlyVersion"; diff --git a/vue-model-api/src/useReplicatedModel.ts b/vue-model-api/src/useReplicatedModel.ts new file mode 100644 index 0000000000..4c42060578 --- /dev/null +++ b/vue-model-api/src/useReplicatedModel.ts @@ -0,0 +1,128 @@ +import type { org } from "@modelix/model-client"; +import type { INodeJS } from "@modelix/ts-model-api"; +import { useLastPromiseEffect } from "./internal/useLastPromiseEffect"; +import type { MaybeRefOrGetter, Ref } from "vue"; +import { shallowRef, toValue } from "vue"; +import type { ReactiveINodeJS } from "./internal/ReactiveINodeJS"; +import { toReactiveINodeJS } from "./internal/ReactiveINodeJS"; +import { Cache } from "./internal/Cache"; +import { handleChange } from "./internal/handleChange"; + +type ClientJS = org.modelix.model.client2.ClientJS; +type ReplicatedModelJS = org.modelix.model.client2.ReplicatedModelJS; +type ChangeJS = org.modelix.model.client2.ChangeJS; + +function isDefined(value: T | null | undefined): value is T { + return value !== null && value !== undefined; +} + +/** + * Creates a replicated model for a given repository and branch. + * A replicated model exposes a branch that can be used to read and write model data. + * The written model data is automatically synced to the model server. + * Changed from the model server are automatically synced to the branch in the replicated model + * + * Also creates root node that uses Vues reactivity and can be used in Vue like a reactive object. + * Changes to model data trigger recalculation of computed properties or re-rendering of components using that data. + * + * Calling the returned dispose function stops syncing the root node to the underlying branch on the server. + * + * @param client - Reactive reference of a client to a model server. + * @param repositoryId - Reactive reference of a repositoryId on the model server. + * @param branchId - Reactive reference of a branchId in the repository of the model server. + * + * @returns {Object} values Wrapper around different returned values. + * @returns {Ref} values.rootNode Reactive reference to the replicated model for the specified branch. + * @returns {Ref} values.rootNode Reactive reference to the root node with Vue.js reactivity for the specified branch. + * @returns {() => void} values.dispose A function to manually dispose the root node. + * @returns {Ref} values.error Reactive reference to a connection error. + */ +export function useReplicatedModel( + client: MaybeRefOrGetter, + repositoryId: MaybeRefOrGetter, + branchId: MaybeRefOrGetter, + idScheme: MaybeRefOrGetter< + org.modelix.model.client2.IdSchemeJS | null | undefined + >, +): { + replicatedModel: Ref; + rootNode: Ref; + dispose: () => void; + error: Ref; +} { + // Use `replicatedModel` to access the replicated model without tracking overhead of Vue.js. + let replicatedModel: ReplicatedModelJS | null = null; + const replicatedModelRef: Ref = shallowRef(null); + const rootNodeRef: Ref = shallowRef(null); + const errorRef: Ref = shallowRef(null); + + const dispose = () => { + // Using `replicatedModelRef.value` here would create a circular dependency. + // `toRaw` does not work on `Ref<>`. + if (replicatedModel !== null) { + replicatedModel.dispose(); + } + replicatedModelRef.value = null; + rootNodeRef.value = null; + errorRef.value = null; + }; + + useLastPromiseEffect( + () => { + dispose(); + const clientValue = toValue(client); + if (!isDefined(clientValue)) { + return; + } + const repositoryIdValue = toValue(repositoryId); + if (!isDefined(repositoryIdValue)) { + return; + } + const branchIdValue = toValue(branchId); + if (!isDefined(branchIdValue)) { + return; + } + const idSchemeValue = toValue(idScheme); + if (!isDefined(idSchemeValue)) { + return; + } + const cache = new Cache(); + return clientValue + .startReplicatedModel(repositoryIdValue, branchIdValue, idSchemeValue) + .then((replicatedModel) => ({ replicatedModel, cache })); + }, + ( + { replicatedModel: connectedReplicatedModel, cache }, + isResultOfLastStartedPromise, + ) => { + if (isResultOfLastStartedPromise) { + replicatedModel = connectedReplicatedModel; + const branch = replicatedModel.getBranch(); + branch.addListener((change: ChangeJS) => { + if (cache === null) { + throw Error("The cache is unexpectedly not set up."); + } + handleChange(change, cache); + }); + const unreactiveRootNode = branch.rootNode; + const reactiveRootNode = toReactiveINodeJS(unreactiveRootNode, cache); + replicatedModelRef.value = replicatedModel; + rootNodeRef.value = reactiveRootNode; + } else { + connectedReplicatedModel.dispose(); + } + }, + (reason, isResultOfLastStartedPromise) => { + if (isResultOfLastStartedPromise) { + errorRef.value = reason; + } + }, + ); + + return { + replicatedModel: replicatedModelRef, + rootNode: rootNodeRef, + dispose, + error: errorRef, + }; +} diff --git a/vue-model-api/src/useReplicatedModels.ts b/vue-model-api/src/useReplicatedModels.ts index 137174b35b..aa0e89b37e 100644 --- a/vue-model-api/src/useReplicatedModels.ts +++ b/vue-model-api/src/useReplicatedModels.ts @@ -1,8 +1,8 @@ -import { org } from "@modelix/model-client"; +import type { org } from "@modelix/model-client"; import type { INodeJS } from "@modelix/ts-model-api"; import { useLastPromiseEffect } from "./internal/useLastPromiseEffect"; import type { MaybeRefOrGetter, Ref } from "vue"; -import { shallowRef, toValue, computed } from "vue"; +import { shallowRef, toValue } from "vue"; import type { ReactiveINodeJS } from "./internal/ReactiveINodeJS"; import { toReactiveINodeJS } from "./internal/ReactiveINodeJS"; import { Cache } from "./internal/Cache"; @@ -120,68 +120,3 @@ export function useReplicatedModels( error: errorRef, }; } - -/** - * Creates a replicated model for a given repository and branch. - * A replicated model exposes a branch that can be used to read and write model data. - * The written model data is automatically synced to the model server. - * Changed from the model server are automatically synced to the branch in the replicated model - * - * Also creates root node that uses Vues reactivity and can be used in Vue like a reactive object. - * Changes to model data trigger recalculation of computed properties or re-rendering of components using that data. - * - * Calling the returned dispose function stops syncing the root node to the underlying branch on the server. - * - * @param client - Reactive reference of a client to a model server. - * @param repositoryId - Reactive reference of a repositoryId on the model server. - * @param branchId - Reactive reference of a branchId in the repository of the model server. - * - * @returns {Object} values Wrapper around different returned values. - * @returns {Ref} values.rootNode Reactive reference to the replicated model for the specified branch. - * @returns {Ref} values.rootNode Reactive reference to the root node with Vue.js reactivity for the specified branch. - * @returns {() => void} values.dispose A function to manually dispose the root node. - * @returns {Ref} values.error Reactive reference to a connection error. - * - * @deprecated Use {@link useReplicatedModels} instead. - */ -export function useReplicatedModel( - client: MaybeRefOrGetter, - repositoryId: MaybeRefOrGetter, - branchId: MaybeRefOrGetter, - idScheme: MaybeRefOrGetter< - org.modelix.model.client2.IdSchemeJS | null | undefined - >, -): { - replicatedModel: Ref; - rootNode: Ref; - dispose: () => void; - error: Ref; -} { - const models = computed(() => { - const repositoryIdValue = toValue(repositoryId); - const branchIdValue = toValue(branchId); - const idSchemeValue = toValue(idScheme); - if (!repositoryIdValue || !branchIdValue || !idSchemeValue) { - return null; - } - return [ - new org.modelix.model.client2.ReplicatedModelParameters( - repositoryIdValue, - branchIdValue, - idSchemeValue, - ), - ]; - }); - - const result = useReplicatedModels(client, models); - - // Extract the single root node from the array for backward compatibility - const rootNode = computed(() => result.rootNodes.value[0] ?? null); - - return { - replicatedModel: result.replicatedModel, - rootNode: rootNode, - dispose: result.dispose, - error: result.error, - }; -}