Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Bugfix] Do not allow the user method to change its return types over its lifecycle #567

Merged
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
5 changes: 5 additions & 0 deletions .changeset/slimy-worms-fail.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@segment/analytics-next': patch
---

Do not allow the "user" method to change its return types over its lifecycle. We should always return a promise for wrapped methods in AnalyticsBrowser, regardless if the underlying Analytics method is sync or async.
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Context } from '../../core/context'
import * as Factory from '../../test-helpers/factories'
import { sleep } from '../../test-helpers/sleep'
import { setGlobalCDNUrl } from '../../lib/parse-cdn'
import { User } from '../../core/user'

jest.mock('unfetch')

Expand Down Expand Up @@ -59,19 +60,38 @@ describe('Pre-initialization', () => {
expect(trackSpy).toBeCalledTimes(1)
})

test('"return types should not change over the lifecycle for ordinary methods', async () => {
test('"return types should not change over the lifecycle for async methods', async () => {
const ajsBrowser = AnalyticsBrowser.load({ writeKey })

const trackCtxPromise1 = ajsBrowser.track('foo', { name: 'john' })
expect(trackCtxPromise1).toBeInstanceOf(Promise)
const ctx1 = await trackCtxPromise1
expect(ctx1).toBeInstanceOf(Context)
await ajsBrowser

// loaded
const trackCtxPromise2 = ajsBrowser.track('foo', { name: 'john' })
expect(trackCtxPromise2).toBeInstanceOf(Promise)
const ctx2 = await trackCtxPromise2
expect(ctx2).toBeInstanceOf(Context)

expect(await trackCtxPromise1).toBeInstanceOf(Context)
expect(await trackCtxPromise2).toBeInstanceOf(Context)
})

test('return types should not change over lifecycle for sync methods', async () => {
const ajsBrowser = AnalyticsBrowser.load({ writeKey })
const user = ajsBrowser.user()
expect(user).toBeInstanceOf(Promise)
await ajsBrowser

// loaded
const user2 = ajsBrowser.user()
expect(user2).toBeInstanceOf(Promise)

expect(await user).toBeInstanceOf(User)
expect(await user2).toBeInstanceOf(User)
})

test('version should return version', async () => {
const ajsBrowser = AnalyticsBrowser.load({ writeKey })
expect(typeof ajsBrowser.VERSION).toBe('string')
})

test('If a user sends multiple events, all of those event gets flushed', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Analytics } from '@/core/analytics'
import { Context } from '@/core/context'
import { AnalyticsBrowser } from '@/browser'
import { assertNotAny, assertIs } from '@/test-helpers/type-assertions'
import { Group } from '../../../core/user'
import { Group, User } from '../../../core/user'

/**
* These are general typescript definition tests;
Expand Down Expand Up @@ -67,4 +67,11 @@ export default {
assertIs<Promise<Context>>(grpResult)
}
},
'User should have the correct type': () => {
const ajs = AnalyticsBrowser.load({ writeKey: 'foo' })
{
const grpResult = ajs.user()
assertIs<Promise<User>>(grpResult)
}
},
}
52 changes: 52 additions & 0 deletions packages/browser/src/core/buffer/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
import { Analytics } from '../../analytics'
import { Context } from '../../context'
import { sleep } from '@/test-helpers/sleep'
import { User } from '../../user'

describe('PreInitMethodCallBuffer', () => {
describe('push', () => {
Expand Down Expand Up @@ -99,6 +100,57 @@ describe('AnalyticsBuffered', () => {
expect(context).toEqual(ctx)
})

it('should wrap async methods in a promise', async () => {
const ajs = new Analytics({ writeKey: 'foo' })
const ctx = new Context({ type: 'track' })

ajs.track = () => Promise.resolve(ctx)

const buffered = new AnalyticsBuffered((buffer) => {
return new Promise((resolve) =>
setTimeout(() => {
flushAnalyticsCallsInNewTask(ajs, buffer)
resolve([ajs, ctx])
}, 0)
)
})
const result = buffered.track('foo')
expect(result).toBeInstanceOf(Promise)

await buffered

const result2 = buffered.track('bar')
expect(result2).toBeInstanceOf(Promise)

expect(await result).toBeInstanceOf(Context)
expect(await result2).toBeInstanceOf(Context)
})

it('should wrap synchronous methods in a promise', async () => {
const ajs = new Analytics({ writeKey: 'foo' })
ajs.user = () => new User()
const ctx = new Context({ type: 'track' })

const buffered = new AnalyticsBuffered((buffer) => {
return new Promise((resolve) =>
setTimeout(() => {
flushAnalyticsCallsInNewTask(ajs, buffer)
resolve([ajs, ctx])
}, 0)
)
})
const result = buffered.user()
expect(result).toBeInstanceOf(Promise)

await buffered

const result2 = buffered.user()
expect(result2).toBeInstanceOf(Promise)

expect(await result).toBeInstanceOf(User)
expect(await result2).toBeInstanceOf(User)
})

describe('the "this" value of proxied analytics methods', () => {
test('should be the ajs instance for non-chainable methods (that return a promise)', async () => {
const ajs = new Analytics({ writeKey: 'foo' })
Expand Down
3 changes: 2 additions & 1 deletion packages/browser/src/core/buffer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,8 @@ export class AnalyticsBuffered
...args: Parameters<Analytics[T]>
): Promise<ReturnTypeUnwrap<Analytics[T]>> => {
if (this.instance) {
return (this.instance[methodName] as Function)(...args)
const result = (this.instance[methodName] as Function)(...args)
return Promise.resolve(result)
}

return new Promise((resolve, reject) => {
Expand Down
8 changes: 2 additions & 6 deletions packages/browser/src/test-helpers/type-assertions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,7 @@ type NotUnknown<T> = unknown extends T ? never : T
type NotTopType<T> = NotAny<T> & NotUnknown<T>

// this is not meant to be run, just for type tests
export function assertNotAny<T>(val: NotTopType<T>) {
console.log(val)
}
export function assertNotAny<T>(_val: NotTopType<T>) {}

// this is not meant to be run, just for type tests
export function assertIs<T extends SomeType, SomeType = any>(val: T) {
console.log(val)
}
export function assertIs<T extends SomeType, SomeType = any>(_val: T) {}