@multiplayer-app/session-recorder-react 2.0.17-alpha.9 → 2.0.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/docs/img/header-js.png +0 -0
- package/src/ErrorBoundary.tsx +0 -38
- package/src/context/SessionRecorderContext.tsx +0 -100
- package/src/context/SessionRecorderStore.ts +0 -20
- package/src/context/createStore.ts +0 -37
- package/src/context/useSessionRecorderStore.ts +0 -47
- package/src/context/useStoreSelector.ts +0 -36
- package/src/index.ts +0 -8
- package/src/navigation.ts +0 -86
- package/src/utils/shallowEqual.ts +0 -20
- package/tsconfig.json +0 -19
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@multiplayer-app/session-recorder-react",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.18",
|
|
4
4
|
"description": "Multiplayer Fullstack Session Recorder for React (browser wrapper)",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Multiplayer Software, Inc.",
|
|
@@ -35,8 +35,8 @@
|
|
|
35
35
|
"@opentelemetry/api": "^1.9.0"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@multiplayer-app/session-recorder-browser": "2.0.
|
|
39
|
-
"@multiplayer-app/session-recorder-common": "2.0.
|
|
38
|
+
"@multiplayer-app/session-recorder-browser": "2.0.18",
|
|
39
|
+
"@multiplayer-app/session-recorder-common": "2.0.18"
|
|
40
40
|
},
|
|
41
41
|
"devDependencies": {
|
|
42
42
|
"eslint": "10.1.0",
|
package/docs/img/header-js.png
DELETED
|
Binary file
|
package/src/ErrorBoundary.tsx
DELETED
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
import React from 'react'
|
|
2
|
-
import SessionRecorder from '@multiplayer-app/session-recorder-browser'
|
|
3
|
-
|
|
4
|
-
export type ErrorBoundaryProps = {
|
|
5
|
-
children: React.ReactNode
|
|
6
|
-
/** Optional fallback UI to render when an error is caught */
|
|
7
|
-
fallback?: React.ReactNode
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
type State = { hasError: boolean }
|
|
11
|
-
|
|
12
|
-
export class ErrorBoundary extends React.Component<ErrorBoundaryProps, State> {
|
|
13
|
-
constructor(props: ErrorBoundaryProps) {
|
|
14
|
-
super(props)
|
|
15
|
-
this.state = { hasError: false }
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
static getDerivedStateFromError(): State {
|
|
19
|
-
return { hasError: true }
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
componentDidCatch(error: unknown, errorInfo: React.ErrorInfo): void {
|
|
23
|
-
try {
|
|
24
|
-
SessionRecorder.captureException(error, errorInfo)
|
|
25
|
-
} catch (_e) {
|
|
26
|
-
// no-op
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
render(): React.ReactNode {
|
|
31
|
-
if (this.state.hasError) {
|
|
32
|
-
return this.props.fallback ?? null
|
|
33
|
-
}
|
|
34
|
-
return this.props.children
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export default ErrorBoundary
|
|
@@ -1,100 +0,0 @@
|
|
|
1
|
-
import React, { createContext, useContext, useEffect, useCallback, type PropsWithChildren } from 'react'
|
|
2
|
-
import SessionRecorder, { SessionState, SessionType } from '@multiplayer-app/session-recorder-browser'
|
|
3
|
-
import { sessionRecorderStore } from './SessionRecorderStore'
|
|
4
|
-
|
|
5
|
-
type SessionRecorderOptions = any
|
|
6
|
-
|
|
7
|
-
interface SessionRecorderContextType {
|
|
8
|
-
instance: typeof SessionRecorder
|
|
9
|
-
startSession: (sessionType?: SessionType) => void | Promise<void>
|
|
10
|
-
stopSession: (comment?: string) => Promise<void>
|
|
11
|
-
pauseSession: () => Promise<void>
|
|
12
|
-
resumeSession: () => Promise<void>
|
|
13
|
-
cancelSession: () => Promise<void>
|
|
14
|
-
saveSession: () => Promise<any>
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
const SessionRecorderContext = createContext<SessionRecorderContextType | null>(null)
|
|
18
|
-
|
|
19
|
-
export interface SessionRecorderProviderProps extends PropsWithChildren {
|
|
20
|
-
options?: SessionRecorderOptions
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export const SessionRecorderProvider: React.FC<SessionRecorderProviderProps> = ({ children, options }) => {
|
|
24
|
-
useEffect(() => {
|
|
25
|
-
if (options) {
|
|
26
|
-
SessionRecorder.init(options)
|
|
27
|
-
}
|
|
28
|
-
sessionRecorderStore.setState({ isInitialized: SessionRecorder.isInitialized })
|
|
29
|
-
}, [])
|
|
30
|
-
|
|
31
|
-
useEffect(() => {
|
|
32
|
-
sessionRecorderStore.setState({
|
|
33
|
-
sessionState: SessionRecorder.sessionState,
|
|
34
|
-
sessionType: SessionRecorder.sessionType,
|
|
35
|
-
})
|
|
36
|
-
|
|
37
|
-
const onStateChange = (sessionState: SessionState) => {
|
|
38
|
-
sessionRecorderStore.setState({ sessionState })
|
|
39
|
-
}
|
|
40
|
-
const onInit = () => {
|
|
41
|
-
sessionRecorderStore.setState({ isInitialized: true })
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
SessionRecorder.on('state-change', onStateChange)
|
|
45
|
-
SessionRecorder.on('init', onInit)
|
|
46
|
-
return () => {
|
|
47
|
-
SessionRecorder.off('state-change', onStateChange)
|
|
48
|
-
SessionRecorder.off('init', onInit)
|
|
49
|
-
}
|
|
50
|
-
}, [])
|
|
51
|
-
|
|
52
|
-
const startSession = useCallback((sessionType: SessionType = SessionType.MANUAL) => {
|
|
53
|
-
return SessionRecorder.start(sessionType)
|
|
54
|
-
}, [])
|
|
55
|
-
|
|
56
|
-
const stopSession = useCallback((comment?: string) => {
|
|
57
|
-
return SessionRecorder.stop(comment)
|
|
58
|
-
}, [])
|
|
59
|
-
|
|
60
|
-
const pauseSession = useCallback(() => {
|
|
61
|
-
return SessionRecorder.pause()
|
|
62
|
-
}, [])
|
|
63
|
-
|
|
64
|
-
const resumeSession = useCallback(() => {
|
|
65
|
-
return SessionRecorder.resume()
|
|
66
|
-
}, [])
|
|
67
|
-
|
|
68
|
-
const cancelSession = useCallback(() => {
|
|
69
|
-
return SessionRecorder.cancel()
|
|
70
|
-
}, [])
|
|
71
|
-
|
|
72
|
-
const saveSession = useCallback(() => {
|
|
73
|
-
return SessionRecorder.save()
|
|
74
|
-
}, [])
|
|
75
|
-
|
|
76
|
-
return (
|
|
77
|
-
<SessionRecorderContext.Provider
|
|
78
|
-
value={{
|
|
79
|
-
instance: SessionRecorder,
|
|
80
|
-
startSession,
|
|
81
|
-
stopSession,
|
|
82
|
-
pauseSession,
|
|
83
|
-
resumeSession,
|
|
84
|
-
cancelSession,
|
|
85
|
-
saveSession,
|
|
86
|
-
}}
|
|
87
|
-
>
|
|
88
|
-
{children}
|
|
89
|
-
{/* No widget component here; consumer can import the browser widget if needed */}
|
|
90
|
-
</SessionRecorderContext.Provider>
|
|
91
|
-
)
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
export const useSessionRecorder = (): SessionRecorderContextType => {
|
|
95
|
-
const context = useContext(SessionRecorderContext)
|
|
96
|
-
if (!context) {
|
|
97
|
-
throw new Error('useSessionRecorder must be used within a SessionRecorderProvider')
|
|
98
|
-
}
|
|
99
|
-
return context
|
|
100
|
-
}
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
import { createStore, type Store } from './createStore'
|
|
2
|
-
import { SessionState, SessionType } from '@multiplayer-app/session-recorder-browser'
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
export type SessionRecorderState = {
|
|
6
|
-
isInitialized: boolean;
|
|
7
|
-
sessionType: SessionType | null;
|
|
8
|
-
sessionState: SessionState | null;
|
|
9
|
-
isOnline: boolean;
|
|
10
|
-
error: string | null;
|
|
11
|
-
};
|
|
12
|
-
|
|
13
|
-
export const sessionRecorderStore: Store<SessionRecorderState> =
|
|
14
|
-
createStore<SessionRecorderState>({
|
|
15
|
-
isInitialized: false,
|
|
16
|
-
sessionType: null,
|
|
17
|
-
sessionState: null,
|
|
18
|
-
isOnline: true,
|
|
19
|
-
error: null,
|
|
20
|
-
})
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
export type SessionRecorderState = {
|
|
2
|
-
isInitialized: boolean
|
|
3
|
-
sessionType: any | null
|
|
4
|
-
sessionState: any | null
|
|
5
|
-
isOnline: boolean
|
|
6
|
-
error: string | null
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
type Listener<T> = (state: T, prev: T) => void
|
|
10
|
-
|
|
11
|
-
export type Store<T> = {
|
|
12
|
-
getState: () => T
|
|
13
|
-
setState: (partial: Partial<T> | ((prev: T) => T)) => void
|
|
14
|
-
subscribe: (listener: Listener<T>) => () => void
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export function createStore<T extends object>(initialState: T): Store<T> {
|
|
18
|
-
let state = initialState
|
|
19
|
-
const listeners = new Set<Listener<T>>()
|
|
20
|
-
|
|
21
|
-
const getState = () => state
|
|
22
|
-
|
|
23
|
-
const setState: Store<T>['setState'] = (partial) => {
|
|
24
|
-
const prev = state
|
|
25
|
-
const next = typeof partial === 'function' ? (partial as (p: T) => T)(prev) : ({ ...prev, ...partial } as T)
|
|
26
|
-
if (Object.is(next, prev)) return
|
|
27
|
-
state = next
|
|
28
|
-
listeners.forEach((l) => l(state, prev))
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
const subscribe: Store<T>['subscribe'] = (listener) => {
|
|
32
|
-
listeners.add(listener)
|
|
33
|
-
return () => listeners.delete(listener)
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
return { getState, setState, subscribe }
|
|
37
|
-
}
|
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
import { SessionState, SessionType } from '@multiplayer-app/session-recorder-browser'
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
type SessionRecorderState,
|
|
5
|
-
sessionRecorderStore,
|
|
6
|
-
} from './SessionRecorderStore'
|
|
7
|
-
import { useStoreSelector } from './useStoreSelector'
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Select a derived slice from the shared Session Recorder store.
|
|
11
|
-
* Works in both React (web) and React Native since the store shape is identical.
|
|
12
|
-
*
|
|
13
|
-
* @param selector - Function that maps the full store state to the slice you need
|
|
14
|
-
* @param equalityFn - Optional comparator to avoid unnecessary re-renders
|
|
15
|
-
* @returns The selected slice of state
|
|
16
|
-
*/
|
|
17
|
-
export function useSessionRecorderStore<TSlice>(
|
|
18
|
-
selector: (s: SessionRecorderState) => TSlice,
|
|
19
|
-
equalityFn?: (a: TSlice, b: TSlice) => boolean,
|
|
20
|
-
): TSlice {
|
|
21
|
-
return useStoreSelector<SessionRecorderState, TSlice>(
|
|
22
|
-
sessionRecorderStore,
|
|
23
|
-
selector,
|
|
24
|
-
equalityFn,
|
|
25
|
-
)
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Read the current session recording state (started, paused, stopped).
|
|
30
|
-
*/
|
|
31
|
-
export function useSessionRecordingState() {
|
|
32
|
-
return useSessionRecorderStore<SessionState | null>((s) => s.sessionState)
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Read the current session type (MANUAL/CONTINUOUS).
|
|
37
|
-
*/
|
|
38
|
-
export function useSessionType() {
|
|
39
|
-
return useSessionRecorderStore<SessionType | null>((s) => s.sessionType)
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Check whether the Session Recorder has been initialized.
|
|
44
|
-
*/
|
|
45
|
-
export function useIsInitialized() {
|
|
46
|
-
return useSessionRecorderStore<boolean>((s) => s.isInitialized)
|
|
47
|
-
}
|
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
import { useEffect, useRef, useState } from 'react'
|
|
2
|
-
import { type Store } from './createStore'
|
|
3
|
-
import { shallowEqual } from '../utils/shallowEqual'
|
|
4
|
-
|
|
5
|
-
export function useStoreSelector<TState extends object, TSlice>(
|
|
6
|
-
store: Store<TState>,
|
|
7
|
-
selector: (state: TState) => TSlice,
|
|
8
|
-
equalityFn: (a: TSlice, b: TSlice) => boolean = Object.is,
|
|
9
|
-
): TSlice {
|
|
10
|
-
const latestSelectorRef = useRef(selector)
|
|
11
|
-
const latestEqualityRef = useRef(equalityFn)
|
|
12
|
-
latestSelectorRef.current = selector
|
|
13
|
-
latestEqualityRef.current = equalityFn
|
|
14
|
-
|
|
15
|
-
const [slice, setSlice] = useState<TSlice>(() =>
|
|
16
|
-
latestSelectorRef.current(store.getState()),
|
|
17
|
-
)
|
|
18
|
-
|
|
19
|
-
useEffect(() => {
|
|
20
|
-
function handleChange(nextState: TState, prevState: TState) {
|
|
21
|
-
const nextSlice = latestSelectorRef.current(nextState)
|
|
22
|
-
const prevSlice = latestSelectorRef.current(prevState)
|
|
23
|
-
if (!latestEqualityRef.current(nextSlice, prevSlice)) {
|
|
24
|
-
setSlice(nextSlice)
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
const unsubscribe = store.subscribe(handleChange)
|
|
28
|
-
// Sync once in case changed between render and effect
|
|
29
|
-
handleChange(store.getState(), store.getState())
|
|
30
|
-
return unsubscribe
|
|
31
|
-
}, [store])
|
|
32
|
-
|
|
33
|
-
return slice
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export const shallow = shallowEqual
|
package/src/index.ts
DELETED
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
export * from '@multiplayer-app/session-recorder-browser'
|
|
2
|
-
export { default } from '@multiplayer-app/session-recorder-browser'
|
|
3
|
-
|
|
4
|
-
export * from './context/SessionRecorderContext'
|
|
5
|
-
export * from './context/useSessionRecorderStore'
|
|
6
|
-
export { ErrorBoundary } from './ErrorBoundary'
|
|
7
|
-
export { useNavigationRecorder } from './navigation'
|
|
8
|
-
export type { UseNavigationRecorderOptions } from './navigation'
|
package/src/navigation.ts
DELETED
|
@@ -1,86 +0,0 @@
|
|
|
1
|
-
import { useEffect, useRef } from 'react'
|
|
2
|
-
import SessionRecorderBrowser, {
|
|
3
|
-
NavigationSignal,
|
|
4
|
-
} from '@multiplayer-app/session-recorder-browser'
|
|
5
|
-
|
|
6
|
-
export interface UseNavigationRecorderOptions
|
|
7
|
-
extends Partial<Omit<NavigationSignal, 'path' | 'timestamp'>> {
|
|
8
|
-
/**
|
|
9
|
-
* Overrides the path sent to the recorder. Defaults to the provided pathname argument.
|
|
10
|
-
*/
|
|
11
|
-
path?: string
|
|
12
|
-
/**
|
|
13
|
-
* When true (default), document.title is captured if available.
|
|
14
|
-
*/
|
|
15
|
-
captureDocumentTitle?: boolean
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* React Router compatible navigation recorder hook.
|
|
20
|
-
* Call inside a component where you can access current location and navigation events.
|
|
21
|
-
* Example:
|
|
22
|
-
* const location = useLocation();
|
|
23
|
-
* useNavigationRecorder(location.pathname);
|
|
24
|
-
*/
|
|
25
|
-
export function useNavigationRecorder(
|
|
26
|
-
pathname: string,
|
|
27
|
-
options?: UseNavigationRecorderOptions,
|
|
28
|
-
): void {
|
|
29
|
-
const optionsRef = useRef(options)
|
|
30
|
-
const hasRecordedInitialRef = useRef(false)
|
|
31
|
-
const lastPathRef = useRef<string | null>(null)
|
|
32
|
-
|
|
33
|
-
useEffect(() => {
|
|
34
|
-
optionsRef.current = options
|
|
35
|
-
}, [options])
|
|
36
|
-
|
|
37
|
-
useEffect(() => {
|
|
38
|
-
if (!pathname || !SessionRecorderBrowser?.navigation) {
|
|
39
|
-
return
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const resolvedOptions = optionsRef.current || {}
|
|
43
|
-
const resolvedPath = resolvedOptions.path ?? pathname
|
|
44
|
-
|
|
45
|
-
if (!resolvedPath) {
|
|
46
|
-
return
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
if (lastPathRef.current === resolvedPath && !resolvedOptions.navigationType) {
|
|
50
|
-
return
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const captureDocumentTitle =
|
|
54
|
-
resolvedOptions.captureDocumentTitle ?? true
|
|
55
|
-
|
|
56
|
-
const signal: NavigationSignal = {
|
|
57
|
-
path: resolvedPath,
|
|
58
|
-
routeName: resolvedOptions.routeName ?? resolvedPath,
|
|
59
|
-
title:
|
|
60
|
-
resolvedOptions.title ??
|
|
61
|
-
(captureDocumentTitle && typeof document !== 'undefined'
|
|
62
|
-
? document.title
|
|
63
|
-
: undefined),
|
|
64
|
-
url: resolvedOptions.url,
|
|
65
|
-
params: resolvedOptions.params,
|
|
66
|
-
state: resolvedOptions.state,
|
|
67
|
-
navigationType:
|
|
68
|
-
resolvedOptions.navigationType ??
|
|
69
|
-
(hasRecordedInitialRef.current ? undefined : 'initial'),
|
|
70
|
-
framework: resolvedOptions.framework ?? 'react',
|
|
71
|
-
source: resolvedOptions.source ?? 'react-router',
|
|
72
|
-
metadata: resolvedOptions.metadata,
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
try {
|
|
76
|
-
SessionRecorderBrowser.navigation.record(signal)
|
|
77
|
-
hasRecordedInitialRef.current = true
|
|
78
|
-
lastPathRef.current = resolvedPath
|
|
79
|
-
} catch (error) {
|
|
80
|
-
if (process.env.NODE_ENV !== 'production') {
|
|
81
|
-
// eslint-disable-next-line no-console
|
|
82
|
-
console.warn('[SessionRecorder][React] Failed to record navigation', error)
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
}, [pathname])
|
|
86
|
-
}
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
export function shallowEqual<T extends Record<string, any>>(
|
|
2
|
-
a: T,
|
|
3
|
-
b: T,
|
|
4
|
-
): boolean {
|
|
5
|
-
if (Object.is(a, b)) return true
|
|
6
|
-
if (!a || !b) return false
|
|
7
|
-
const aKeys = Object.keys(a)
|
|
8
|
-
const bKeys = Object.keys(b)
|
|
9
|
-
if (aKeys.length !== bKeys.length) return false
|
|
10
|
-
for (let i = 0; i < aKeys.length; i++) {
|
|
11
|
-
const key = aKeys[i]
|
|
12
|
-
if (
|
|
13
|
-
!Object.prototype.hasOwnProperty.call(b, key!) ||
|
|
14
|
-
!Object.is(a[key!], b[key!])
|
|
15
|
-
) {
|
|
16
|
-
return false
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
|
-
return true
|
|
20
|
-
}
|
package/tsconfig.json
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"compilerOptions": {
|
|
3
|
-
"target": "ES2020",
|
|
4
|
-
"module": "ESNext",
|
|
5
|
-
"moduleResolution": "Node",
|
|
6
|
-
"declaration": true,
|
|
7
|
-
"declarationMap": true,
|
|
8
|
-
"sourceMap": true,
|
|
9
|
-
"outDir": "dist",
|
|
10
|
-
"strict": true,
|
|
11
|
-
"esModuleInterop": true,
|
|
12
|
-
"skipLibCheck": true,
|
|
13
|
-
"forceConsistentCasingInFileNames": true,
|
|
14
|
-
"jsx": "react-jsx",
|
|
15
|
-
"types": ["react"]
|
|
16
|
-
},
|
|
17
|
-
"include": ["src/**/*"],
|
|
18
|
-
"exclude": ["node_modules", "dist"]
|
|
19
|
-
}
|