diff --git a/src/plugins/objects/objects.ts b/src/plugins/objects/objects.ts index 45223cee8..5c0526ec0 100644 --- a/src/plugins/objects/objects.ts +++ b/src/plugins/objects/objects.ts @@ -71,7 +71,14 @@ export class Objects { * This is useful when working with multiple channels with different underlying data structure. */ async getRoot(): Promise> { - this.throwIfInvalidAccessApiConfiguration(); + // Check for channel mode first + this._throwIfMissingChannelMode('object_subscribe'); + + // Ensure channel is attached before proceeding with getRoot operation + await this._ensureChannelAttached(); + + // Now that we're attached, check for any remaining invalid states + this._throwIfInChannelState(['failed']); // if we're not synced yet, wait for sync sequence to finish before returning root if (this._state !== ObjectsState.synced) { @@ -490,6 +497,25 @@ export class Objects { } } + private async _ensureChannelAttached(): Promise { + switch (this._channel.state) { + case 'attached': + case 'suspended': + // Channel is attached or suspended, proceed with the operation + return; + case 'initialized': + case 'detached': + case 'detaching': + case 'attaching': + // Channel needs to be attached + await this._channel.attach(); + return; + default: + // For 'failed' state or any other invalid state + throw this._client.ErrorInfo.fromValues(this._channel.invalidStateError()); + } + } + private _throwIfInChannelState(channelState: API.ChannelState[]): void { if (channelState.includes(this._channel.state)) { throw this._client.ErrorInfo.fromValues(this._channel.invalidStateError()); diff --git a/test/realtime/objects.test.js b/test/realtime/objects.test.js index 220694199..05cc3b6f2 100644 --- a/test/realtime/objects.test.js +++ b/test/realtime/objects.test.js @@ -441,6 +441,52 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }, client); }); + /** @nospec */ + it('getRoot() on unattached channel automatically attaches and waits for sync', async function () { + const helper = this.test.helper; + const client = RealtimeWithObjects(helper); + + await helper.monitorConnectionThenCloseAndFinishAsync(async () => { + const channel = client.channels.get('channel', channelOptionsWithObjects()); + const objects = channel.objects; + + // Verify channel is initially not attached + expect(channel.state).to.equal('initialized', 'Channel should be in initialized state'); + + // Call getRoot() on unattached channel - this should automatically attach and resolve + const root = await objects.getRoot(); + + // Channel should now be attached (or at least no longer initialized) + expect(channel.state).to.equal('attached', 'Channel should be attached after getRoot() call'); + expect(root).to.be.an('object', 'getRoot should return a root object'); + expect(root.size()).to.equal(0, 'Root should be empty for new channel'); + }, client); + }); + + /** @nospec */ + it('getRoot() resolves promptly when called on unattached channel (regression test for hanging promise)', async function () { + const helper = this.test.helper; + const client = RealtimeWithObjects(helper); + + await helper.monitorConnectionThenCloseAndFinishAsync(async () => { + const channel = client.channels.get('channel', channelOptionsWithObjects()); + const objects = channel.objects; + + // Set up a timeout to catch if getRoot() hangs + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error('getRoot() timed out')), 5000); + }); + + // Race between getRoot and timeout - getRoot should win by completing quickly + const result = await Promise.race([ + objects.getRoot(), + timeoutPromise + ]); + + expect(result).to.be.an('object', 'getRoot should return a root object without hanging'); + }, client); + }); + const primitiveKeyData = [ { key: 'stringKey', data: { string: 'stringValue' } }, { key: 'emptyStringKey', data: { string: '' } },