Skip to content
Draft
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
28 changes: 27 additions & 1 deletion src/plugins/objects/objects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,14 @@ export class Objects {
* This is useful when working with multiple channels with different underlying data structure.
*/
async getRoot<T extends API.LiveMapType = API.DefaultRoot>(): Promise<LiveMap<T>> {
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) {
Expand Down Expand Up @@ -490,6 +497,25 @@ export class Objects {
}
}

private async _ensureChannelAttached(): Promise<void> {
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());
Expand Down
46 changes: 46 additions & 0 deletions test/realtime/objects.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: '' } },
Expand Down