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

Delay initialization (lazy loading) #637

Merged
merged 16 commits into from
Nov 1, 2022
5 changes: 5 additions & 0 deletions .changeset/friendly-mails-heal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@segment/analytics-next': minor
---

Add ability to delay initialization
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,29 @@ document.body?.addEventListener('click', () => {
})
```

## Lazy / Delayed Loading
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for adding this section!

You can load a buffered version of analytics that requires `.load` to be explicitly called before initatiating any network activity. This can be useful if you want to wait for a user to consent before fetching any tracking destinations or sending buffered events to segment.

- ⚠️ ️`.load` should only be called _once_.

```ts
export const analytics = new AnalyticsBrowser()

analytics.identify("hello world")

if (userConsentsToBeingTracked) {
analytics.load({ writeKey: '<YOUR_WRITE_KEY>' }) // destinations loaded, enqueued events are flushed
}
```
This strategy also comes in handy if you have some settings that are fetched asynchronously.
```ts
const analytics = new AnalyticsBrowser()
fetchWriteKey().then(writeKey => analytics.load({ writeKey }))

analytics.identify("hello world")
```

## Usage in Common Frameworks
### using `React` (Simple)

```tsx
Expand Down
2 changes: 1 addition & 1 deletion packages/browser/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
"size-limit": [
{
"path": "dist/umd/index.js",
"limit": "27.1 KB"
"limit": "27.2 KB"
}
],
"dependencies": {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { sleep } from '@segment/analytics-core'
import unfetch from 'unfetch'
import { AnalyticsBrowser } from '..'
import { Analytics } from '../../core/analytics'
import { createSuccess } from '../../test-helpers/factories'

jest.mock('unfetch')

const mockFetchSettingsSuccessResponse = () => {
return jest
.mocked(unfetch)
.mockImplementation(() => createSuccess({ integrations: {} }))
}

describe('Lazy initialization', () => {
let trackSpy: jest.SpiedFunction<Analytics['track']>
let fetched: jest.MockedFn<typeof unfetch>
beforeEach(() => {
fetched = mockFetchSettingsSuccessResponse()
trackSpy = jest.spyOn(Analytics.prototype, 'track')
})

it('Should be able to delay initialization ', async () => {
const analytics = new AnalyticsBrowser()
const track = analytics.track('foo')
await sleep(100)
expect(trackSpy).not.toBeCalled()
analytics.load({ writeKey: 'abc' })
await track
expect(trackSpy).toBeCalledWith('foo')
})

it('.load method return an analytics instance', async () => {
const analytics = new AnalyticsBrowser().load({ writeKey: 'foo' })
expect(analytics instanceof AnalyticsBrowser).toBeTruthy()
})

it('should ignore subsequent .load calls', async () => {
const analytics = new AnalyticsBrowser()
await analytics.load({ writeKey: 'my-write-key' })
await analytics.load({ writeKey: 'def' })
expect(fetched).toBeCalledTimes(1)
expect(fetched).toBeCalledWith(
expect.stringContaining(
'https://cdn.segment.com/v1/projects/my-write-key/settings'
)
)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -110,4 +110,16 @@ export default {
}
void AnalyticsBrowser.load({ writeKey: 'foo' }).track('foo', {} as User)
},
'Lazy instantiation should be supported': () => {
const analytics = new AnalyticsBrowser()
assertNotAny(analytics)
assertIs<AnalyticsBrowser>(analytics)
analytics.load({ writeKey: 'foo' })
void analytics.track('foo')
},
'.load should return this': () => {
const analytics = new AnalyticsBrowser().load({ writeKey: 'foo' })
assertNotAny(analytics)
assertIs<AnalyticsBrowser>(analytics)
},
}
48 changes: 40 additions & 8 deletions packages/browser/src/browser/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Plan } from '../core/events'
import { Plugin } from '../core/plugin'
import { MetricsOptions } from '../core/stats/remote-metrics'
import { mergedOptions } from '../lib/merged-options'
import { createDeferred } from '../lib/create-deferred'
import { pageEnrichment } from '../plugins/page-enrichment'
import { remoteLoader, RemotePlugin } from '../plugins/remote-loader'
import type { RoutingRule } from '../plugins/routing-middleware'
Expand All @@ -18,7 +19,6 @@ import {
PreInitMethodCallBuffer,
flushAnalyticsCallsInNewTask,
flushAddSourceMiddleware,
AnalyticsLoader,
flushSetAnonymousID,
flushOn,
} from '../core/buffer'
Expand Down Expand Up @@ -309,12 +309,46 @@ async function loadAnalytics(
}

/**
* The public browser interface for this package.
* Use AnalyticsBrowser.load to create an instance.
* The public browser interface for Segment Analytics
* ```ts
* export const analytics = new AnalyticsBrowser()
* analytics.load({ writeKey: 'foo' })
* ```
* @link https://github.com/segmentio/analytics-next/#readme
*/
export class AnalyticsBrowser extends AnalyticsBuffered {
private constructor(loader: AnalyticsLoader) {
super(loader)
private _resolveLoadStart: (
settings: AnalyticsBrowserSettings,
options: InitOptions
) => void

constructor() {
const { promise: loadStart, resolve: resolveLoadStart } =
createDeferred<Parameters<AnalyticsBrowser['load']>>()

super((buffer) =>
loadStart.then(([settings, options]) =>
loadAnalytics(settings, options, buffer)
)
)

this._resolveLoadStart = (settings, options) =>
resolveLoadStart([settings, options])
}

/**
* Starts loading an analytics instance including:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add a note that this should only be called once? Want to make it clear that calling this with different settings won't essentially 'reload' analytics.

* * Fetching all destinations configured by the user (if applicable).
* * Loading all middleware.
* * Flushing any analytics events that were captured before this method was called.
* ```ts
* export const analytics = new AnalyticsBrowser() // nothing loaded yet
* analytics.load({ writeKey: 'foo' })
* ```
*/
load(settings: AnalyticsBrowserSettings, options: InitOptions = {}): this {
this._resolveLoadStart(settings, options)
return this
}

/**
Expand All @@ -331,9 +365,7 @@ export class AnalyticsBrowser extends AnalyticsBuffered {
settings: AnalyticsBrowserSettings,
options: InitOptions = {}
): AnalyticsBrowser {
return new this((preInitBuffer) =>
loadAnalytics(settings, options, preInitBuffer)
)
return new AnalyticsBrowser().load(settings, options)
}

static standalone(
Expand Down
16 changes: 16 additions & 0 deletions packages/browser/src/lib/create-deferred.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/**
* Return a promise that can be externally resolved
*/
export const createDeferred = <T>() => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a better name than extractPromiseParts 😁

let resolve!: (value: T | PromiseLike<T>) => void
let reject!: (reason: any) => void
const promise = new Promise<T>((_resolve, _reject) => {
resolve = _resolve
reject = _reject
})
return {
resolve,
reject,
promise,
}
}