import { beforeEach, describe, expect, it } from "vitest"; import { Account, Group, co, z } from "../exports.js"; import { CoValueLoadingState, coValueClassFromCoValueClassOrSchema, } from "../internal.js"; import { createJazzTestAccount, setupJazzTestSync } from "../testing.js"; import { JazzError } from "../subscribe/JazzError.js"; import { SubscriptionScope } from "../subscribe/SubscriptionScope.js"; describe("SubscriptionScope", () => { const Person = co.map({ name: z.string(), }); beforeEach(async () => { await setupJazzTestSync(); await createJazzTestAccount({ isCurrentActiveAccount: true, creationProps: { name: "Hermes Puggington" }, }); }); describe("getCurrentValue reference stability", () => { it("returns the same reference when called multiple times with the same loading state", () => { const person = Person.create({ name: "John" }); const node = person.$jazz.raw.core.node; const id = person.$jazz.id; const schema = { ref: coValueClassFromCoValueClassOrSchema(Person), optional: false, }; const scope = new SubscriptionScope(node, true, id, schema); // Simulate LOADING state scope.value = { type: CoValueLoadingState.LOADING, id }; const firstCall = scope.getCurrentValue(); const secondCall = scope.getCurrentValue(); const thirdCall = scope.getCurrentValue(); // All calls should return the same reference expect(firstCall).toBe(secondCall); expect(secondCall).toBe(thirdCall); expect(firstCall).toBe(thirdCall); // Verify it's a NotLoaded value with LOADING state expect(firstCall.$jazz.loadingState).toBe(CoValueLoadingState.LOADING); expect(firstCall.$isLoaded).toBe(false); expect(firstCall.$jazz.id).toBe(id); // Verify the cached value matches expect(scope.unloadedValue).toBe(firstCall); scope.destroy(); }); it("returns different references when the loading state changes", () => { const person = Person.create({ name: "John" }); const node = person.$jazz.raw.core.node; const id = person.$jazz.id; const schema = { ref: coValueClassFromCoValueClassOrSchema(Person), optional: false, }; const scope = new SubscriptionScope(node, true, id, schema); // Start with LOADING state scope.value = { type: CoValueLoadingState.LOADING, id }; const loadingValue = scope.getCurrentValue(); // Switch to UNAVAILABLE state scope.updateValue( new JazzError(id, CoValueLoadingState.UNAVAILABLE, [ { code: CoValueLoadingState.UNAVAILABLE, message: "The value is unavailable", params: { id }, path: [], }, ]), ); const unavailableValue = scope.getCurrentValue(); // Switch to UNAUTHORIZED state scope.updateValue( new JazzError(id, CoValueLoadingState.UNAUTHORIZED, [ { code: CoValueLoadingState.UNAUTHORIZED, message: "The current user is not authorized to access this value", params: { id }, path: [], }, ]), ); const unauthorizedValue = scope.getCurrentValue(); // All should be different references expect(loadingValue).not.toBe(unavailableValue); expect(loadingValue).not.toBe(unauthorizedValue); expect(unavailableValue).not.toBe(unauthorizedValue); // Verify each has the correct loading state expect(loadingValue.$jazz.loadingState).toBe(CoValueLoadingState.LOADING); expect(unavailableValue.$jazz.loadingState).toBe( CoValueLoadingState.UNAVAILABLE, ); expect(unauthorizedValue.$jazz.loadingState).toBe( CoValueLoadingState.UNAUTHORIZED, ); // Verify the cached value is the last one expect(scope.unloadedValue).toBe(unauthorizedValue); scope.destroy(); }); it("maintains reference stability across multiple state transitions", () => { const person = Person.create({ name: "John" }); const node = person.$jazz.raw.core.node; const id = person.$jazz.id; const schema = { ref: coValueClassFromCoValueClassOrSchema(Person), optional: false, }; const scope = new SubscriptionScope(node, true, id, schema); // Get LOADING value multiple times scope.value = { type: CoValueLoadingState.LOADING, id }; const loading1 = scope.getCurrentValue(); const loading2 = scope.getCurrentValue(); expect(loading1).toBe(loading2); // Switch to UNAVAILABLE scope.updateValue( new JazzError(id, CoValueLoadingState.UNAVAILABLE, [ { code: CoValueLoadingState.UNAVAILABLE, message: "The value is unavailable", params: { id }, path: [], }, ]), ); const unavailable1 = scope.getCurrentValue(); expect(unavailable1).not.toBe(loading1); // Get UNAVAILABLE again - should return same reference const unavailable2 = scope.getCurrentValue(); expect(unavailable1).toBe(unavailable2); // Switch to UNAUTHORIZED scope.updateValue( new JazzError(id, CoValueLoadingState.UNAUTHORIZED, [ { code: CoValueLoadingState.UNAUTHORIZED, message: "The current user is not authorized to access this value", params: { id }, path: [], }, ]), ); const unauthorized1 = scope.getCurrentValue(); expect(unauthorized1).not.toBe(unavailable1); // Get UNAUTHORIZED again - should return same reference const unauthorized2 = scope.getCurrentValue(); expect(unauthorized1).toBe(unauthorized2); // Switch back to UNAVAILABLE - should create new reference scope.updateValue( new JazzError(id, CoValueLoadingState.UNAVAILABLE, [ { code: CoValueLoadingState.UNAVAILABLE, message: "The value is unavailable", params: { id }, path: [], }, ]), ); const unavailable3 = scope.getCurrentValue(); expect(unavailable3).not.toBe(unavailable1); expect(unavailable3).not.toBe(unavailable2); // Get UNAVAILABLE again - should return same reference as unavailable3 const unavailable4 = scope.getCurrentValue(); expect(unavailable3).toBe(unavailable4); scope.destroy(); }); it("returns stable reference when switching back to a previously used state after cache was overwritten", () => { const person = Person.create({ name: "John" }); const node = person.$jazz.raw.core.node; const id = person.$jazz.id; const schema = { ref: coValueClassFromCoValueClassOrSchema(Person), optional: false, }; const scope = new SubscriptionScope(node, true, id, schema); // First, get a LOADING value scope.value = { type: CoValueLoadingState.LOADING, id }; const firstLoadingValue = scope.getCurrentValue(); // Switch to UNAVAILABLE (this overwrites the cache) scope.updateValue( new JazzError(id, CoValueLoadingState.UNAVAILABLE, [ { code: CoValueLoadingState.UNAVAILABLE, message: "The value is unavailable", params: { id }, path: [], }, ]), ); const unavailableValue = scope.getCurrentValue(); // Switch back to LOADING (should create a new reference since cache was overwritten) scope.value = { type: CoValueLoadingState.LOADING, id }; const secondLoadingValue = scope.getCurrentValue(); // The second LOADING value should be different from the first // because the cache was overwritten with UNAVAILABLE expect(firstLoadingValue).not.toBe(secondLoadingValue); // But both should have the same loading state expect(firstLoadingValue.$jazz.loadingState).toBe( CoValueLoadingState.LOADING, ); expect(secondLoadingValue.$jazz.loadingState).toBe( CoValueLoadingState.LOADING, ); // The cache should now point to the second LOADING value expect(scope.unloadedValue).toBe(secondLoadingValue); scope.destroy(); }); it("preserves correct loading state in returned value", () => { const person = Person.create({ name: "John" }); const node = person.$jazz.raw.core.node; const id = person.$jazz.id; const schema = { ref: coValueClassFromCoValueClassOrSchema(Person), optional: false, }; const scope = new SubscriptionScope(node, true, id, schema); // Test LOADING state scope.value = { type: CoValueLoadingState.LOADING, id }; const loadingValue = scope.getCurrentValue(); expect(loadingValue.$jazz.loadingState).toBe(CoValueLoadingState.LOADING); expect(loadingValue.$isLoaded).toBe(false); expect(loadingValue.$jazz.id).toBe(id); // Test UNAVAILABLE state scope.updateValue( new JazzError(id, CoValueLoadingState.UNAVAILABLE, [ { code: CoValueLoadingState.UNAVAILABLE, message: "The value is unavailable", params: { id }, path: [], }, ]), ); const unavailableValue = scope.getCurrentValue(); expect(unavailableValue.$jazz.loadingState).toBe( CoValueLoadingState.UNAVAILABLE, ); expect(unavailableValue.$isLoaded).toBe(false); expect(unavailableValue.$jazz.id).toBe(id); // Test UNAUTHORIZED state scope.updateValue( new JazzError(id, CoValueLoadingState.UNAUTHORIZED, [ { code: CoValueLoadingState.UNAUTHORIZED, message: "The current user is not authorized to access this value", params: { id }, path: [], }, ]), ); const unauthorizedValue = scope.getCurrentValue(); expect(unauthorizedValue.$jazz.loadingState).toBe( CoValueLoadingState.UNAUTHORIZED, ); expect(unauthorizedValue.$isLoaded).toBe(false); expect(unauthorizedValue.$jazz.id).toBe(id); scope.destroy(); }); it("returns LOADING state when shouldSendUpdates returns false", () => { const person = Person.create({ name: "John" }); const node = person.$jazz.raw.core.node; const id = person.$jazz.id; const schema = { ref: coValueClassFromCoValueClassOrSchema(Person), optional: false, }; const scope = new SubscriptionScope(node, true, id, schema); // Set up a loaded value with pending children const loadedPerson = Person.create({ name: "Jane" }); scope.updateValue({ type: CoValueLoadingState.LOADED, value: loadedPerson, id: loadedPerson.$jazz.id, }); // Add a pending child to make shouldSendUpdates return false scope.pendingLoadedChildren.add("some-child-id"); const value1 = scope.getCurrentValue(); const value2 = scope.getCurrentValue(); // Should return the same LOADING reference expect(value1).toBe(value2); expect(value1.$jazz.loadingState).toBe(CoValueLoadingState.LOADING); expect(value1.$isLoaded).toBe(false); // Clear pending children scope.pendingLoadedChildren.clear(); // Now should return the loaded value const loadedValue = scope.getCurrentValue(); expect(loadedValue).toBe(loadedPerson); expect(loadedValue.$isLoaded).toBe(true); scope.destroy(); }); it("returns error state from errorFromChildren when present", () => { const person = Person.create({ name: "John" }); const node = person.$jazz.raw.core.node; const id = person.$jazz.id; const schema = { ref: coValueClassFromCoValueClassOrSchema(Person), optional: false, }; const scope = new SubscriptionScope(node, true, id, schema); // Set up a loaded value const loadedPerson = Person.create({ name: "Jane" }); scope.updateValue({ type: CoValueLoadingState.LOADED, value: loadedPerson, id: loadedPerson.$jazz.id, }); // Set up an error from children const childError = new JazzError( "child-id" as any, CoValueLoadingState.UNAVAILABLE, [ { code: CoValueLoadingState.UNAVAILABLE, message: "Child value is unavailable", params: { id: "child-id" }, path: [], }, ], ); scope.errorFromChildren = childError; const value1 = scope.getCurrentValue(); const value2 = scope.getCurrentValue(); // Should return the same UNAVAILABLE reference expect(value1).toBe(value2); expect(value1.$jazz.loadingState).toBe(CoValueLoadingState.UNAVAILABLE); expect(value1.$isLoaded).toBe(false); // Clear the error scope.errorFromChildren = undefined; // Now should return the loaded value const loadedValue = scope.getCurrentValue(); expect(loadedValue).toBe(loadedPerson); expect(loadedValue.$isLoaded).toBe(true); scope.destroy(); }); }); });