0
0
mirror of https://github.com/PostHog/posthog.git synced 2024-11-24 09:14:46 +01:00

feat: dedupe incremental mutations for mobile replay (#19974)

* start passing context around instead of multiple parameters

* start passing a result and context back from conversions

* even more using the context and the results

* get id sequences under control

* manually run prettier

* add lint staged rules for ee TS code

* remove console logs

* start tracking ids as they are processedD

* Add a new test case and so update _all_ of the ids :/

* don't process the same add or update id more than once

* refactor similar closer together

* move keyboard style override into context

* snapshots

* remove constant

* need to fangle context in case select options ever starts to change it
This commit is contained in:
Paul D'Ambra 2024-01-26 16:14:14 +00:00 committed by GitHub
parent 0dd78c59c0
commit 0fdb1e0fe3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 3652 additions and 2832 deletions

View File

@ -0,0 +1,210 @@
{
"data": {
"adds": [
{
"parentId": 183891344,
"wireframe": {
"childWireframes": [
{
"childWireframes": [
{
"disabled": false,
"height": 19,
"id": 52129787,
"style": {
"color": "#000000",
"fontFamily": "sans-serif",
"fontSize": 14,
"horizontalAlign": "left",
"paddingBottom": 0,
"paddingLeft": 0,
"paddingRight": 0,
"paddingTop": 0,
"verticalAlign": "top"
},
"text": "PostHog/posthog-ios",
"type": "text",
"width": 368,
"x": 66,
"y": 556
},
{
"disabled": false,
"height": 19,
"id": 99571736,
"style": {
"color": "#000000",
"fontFamily": "sans-serif",
"fontSize": 14,
"horizontalAlign": "left",
"paddingBottom": 0,
"paddingLeft": 0,
"paddingRight": 0,
"paddingTop": 0,
"verticalAlign": "top"
},
"text": "PostHog iOS integration",
"type": "text",
"width": 150,
"x": 10,
"y": 584
},
{
"disabled": false,
"height": 32,
"id": 240124529,
"style": {
"color": "#000000",
"fontFamily": "sans-serif",
"fontSize": 14,
"horizontalAlign": "center",
"paddingBottom": 6,
"paddingLeft": 32,
"paddingRight": 0,
"paddingTop": 6,
"verticalAlign": "center"
},
"text": "20",
"type": "text",
"width": 48,
"x": 10,
"y": 548
}
],
"disabled": false,
"height": 62,
"id": 209272202,
"style": {},
"width": 406,
"x": 2,
"y": 540
}
],
"disabled": false,
"height": 70,
"id": 142908405,
"style": {
"backgroundImage": "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\n"
},
"width": 411,
"x": 0,
"y": 536
}
},
{
"parentId": 142908405,
"wireframe": {
"childWireframes": [
{
"disabled": false,
"height": 19,
"id": 52129787,
"style": {
"color": "#000000",
"fontFamily": "sans-serif",
"fontSize": 14,
"horizontalAlign": "left",
"paddingBottom": 0,
"paddingLeft": 0,
"paddingRight": 0,
"paddingTop": 0,
"verticalAlign": "top"
},
"text": "PostHog/posthog-ios",
"type": "text",
"width": 368,
"x": 66,
"y": 556
},
{
"disabled": false,
"height": 19,
"id": 99571736,
"style": {
"color": "#000000",
"fontFamily": "sans-serif",
"fontSize": 14,
"horizontalAlign": "left",
"paddingBottom": 0,
"paddingLeft": 0,
"paddingRight": 0,
"paddingTop": 0,
"verticalAlign": "top"
},
"text": "PostHog iOS integration",
"type": "text",
"width": 150,
"x": 10,
"y": 584
},
{
"disabled": false,
"height": 32,
"id": 240124529,
"style": {
"color": "#000000",
"fontFamily": "sans-serif",
"fontSize": 14,
"horizontalAlign": "center",
"paddingBottom": 6,
"paddingLeft": 32,
"paddingRight": 0,
"paddingTop": 6,
"verticalAlign": "center"
},
"text": "20",
"type": "text",
"width": 48,
"x": 10,
"y": 548
}
],
"disabled": false,
"height": 62,
"id": 209272202,
"style": {},
"width": 406,
"x": 2,
"y": 540
}
},
{
"parentId": 209272202,
"wireframe": {
"disabled": false,
"height": 19,
"id": 52129787,
"style": {
"color": "#000000",
"fontFamily": "sans-serif",
"fontSize": 14,
"horizontalAlign": "left",
"paddingBottom": 0,
"paddingLeft": 0,
"paddingRight": 0,
"paddingTop": 0,
"verticalAlign": "top"
},
"text": "PostHog/posthog-ios",
"type": "text",
"width": 368,
"x": 66,
"y": 556
}
}
],
"removes": [
{
"id": 149659273,
"parentId": 47740111
},
{
"id": 151255663,
"parentId": 149659273
}
],
"source": 0
},
"timestamp": 1706104140861,
"type": 3
}

File diff suppressed because it is too large Load Diff

View File

@ -5,7 +5,7 @@ import Ajv, { ErrorObject } from 'ajv'
import { mobileEventWithTime } from './mobile.types'
import mobileSchema from './schema/mobile/rr-mobile-schema.json'
import webSchema from './schema/web/rr-web-schema.json'
import { makeCustomEvent, makeFullEvent, makeIncrementalEvent, makeMetaEvent } from './transformers'
import { makeCustomEvent, makeFullEvent, makeIncrementalEvent, makeMetaEvent } from './transformer/transformers'
const ajv = new Ajv({
allowUnionTypes: true,

View File

@ -1,14 +1,15 @@
import posthogEE from '@posthog/ee/exports'
import {EventType} from '@rrweb/types'
import {ifEeDescribe} from 'lib/ee.test'
import { EventType } from '@rrweb/types'
import { ifEeDescribe } from 'lib/ee.test'
import {PostHogEE} from '../../../frontend/@posthog/ee/types'
import {validateAgainstWebSchema, validateFromMobile} from './index'
import { PostHogEE } from '../../../frontend/@posthog/ee/types'
import * as incrementalSnapshotJson from './__mocks__/increment-with-child-duplication.json'
import { validateAgainstWebSchema, validateFromMobile } from './index'
const unspecifiedBase64ImageURL = 'iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAApgAAAKYB3X3/OAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAANCSURBVEiJtZZPbBtFFMZ/M7ubXdtdb1xSFyeilBapySVU8h8OoFaooFSqiihIVIpQBKci6KEg9Q6H9kovIHoCIVQJJCKE1ENFjnAgcaSGC6rEnxBwA04Tx43t2FnvDAfjkNibxgHxnWb2e/u992bee7tCa00YFsffekFY+nUzFtjW0LrvjRXrCDIAaPLlW0nHL0SsZtVoaF98mLrx3pdhOqLtYPHChahZcYYO7KvPFxvRl5XPp1sN3adWiD1ZAqD6XYK1b/dvE5IWryTt2udLFedwc1+9kLp+vbbpoDh+6TklxBeAi9TL0taeWpdmZzQDry0AcO+jQ12RyohqqoYoo8RDwJrU+qXkjWtfi8Xxt58BdQuwQs9qC/afLwCw8tnQbqYAPsgxE1S6F3EAIXux2oQFKm0ihMsOF71dHYx+f3NND68ghCu1YIoePPQN1pGRABkJ6Bus96CutRZMydTl+TvuiRW1m3n0eDl0vRPcEysqdXn+jsQPsrHMquGeXEaY4Yk4wxWcY5V/9scqOMOVUFthatyTy8QyqwZ+kDURKoMWxNKr2EeqVKcTNOajqKoBgOE28U4tdQl5p5bwCw7BWquaZSzAPlwjlithJtp3pTImSqQRrb2Z8PHGigD4RZuNX6JYj6wj7O4TFLbCO/Mn/m8R+h6rYSUb3ekokRY6f/YukArN979jcW+V/S8g0eT/N3VN3kTqWbQ428m9/8k0P/1aIhF36PccEl6EhOcAUCrXKZXXWS3XKd2vc/TRBG9O5ELC17MmWubD2nKhUKZa26Ba2+D3P+4/MNCFwg59oWVeYhkzgN/JDR8deKBoD7Y+ljEjGZ0sosXVTvbc6RHirr2reNy1OXd6pJsQ+gqjk8VWFYmHrwBzW/n+uMPFiRwHB2I7ih8ciHFxIkd/3Omk5tCDV1t+2nNu5sxxpDFNx+huNhVT3/zMDz8usXC3ddaHBj1GHj/As08fwTS7Kt1HBTmyN29vdwAw+/wbwLVOJ3uAD1wi/dUH7Qei66PfyuRj4Ik9is+hglfbkbfR3cnZm7chlUWLdwmprtCohX4HUtlOcQjLYCu+fzGJH2QRKvP3UNz8bWk1qMxjGTOMThZ3kvgLI5AzFfo379UAAAAASUVORK5CYII='
const unspecifiedBase64ImageURL =
'iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAApgAAAKYB3X3/OAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAANCSURBVEiJtZZPbBtFFMZ/M7ubXdtdb1xSFyeilBapySVU8h8OoFaooFSqiihIVIpQBKci6KEg9Q6H9kovIHoCIVQJJCKE1ENFjnAgcaSGC6rEnxBwA04Tx43t2FnvDAfjkNibxgHxnWb2e/u992bee7tCa00YFsffekFY+nUzFtjW0LrvjRXrCDIAaPLlW0nHL0SsZtVoaF98mLrx3pdhOqLtYPHChahZcYYO7KvPFxvRl5XPp1sN3adWiD1ZAqD6XYK1b/dvE5IWryTt2udLFedwc1+9kLp+vbbpoDh+6TklxBeAi9TL0taeWpdmZzQDry0AcO+jQ12RyohqqoYoo8RDwJrU+qXkjWtfi8Xxt58BdQuwQs9qC/afLwCw8tnQbqYAPsgxE1S6F3EAIXux2oQFKm0ihMsOF71dHYx+f3NND68ghCu1YIoePPQN1pGRABkJ6Bus96CutRZMydTl+TvuiRW1m3n0eDl0vRPcEysqdXn+jsQPsrHMquGeXEaY4Yk4wxWcY5V/9scqOMOVUFthatyTy8QyqwZ+kDURKoMWxNKr2EeqVKcTNOajqKoBgOE28U4tdQl5p5bwCw7BWquaZSzAPlwjlithJtp3pTImSqQRrb2Z8PHGigD4RZuNX6JYj6wj7O4TFLbCO/Mn/m8R+h6rYSUb3ekokRY6f/YukArN979jcW+V/S8g0eT/N3VN3kTqWbQ428m9/8k0P/1aIhF36PccEl6EhOcAUCrXKZXXWS3XKd2vc/TRBG9O5ELC17MmWubD2nKhUKZa26Ba2+D3P+4/MNCFwg59oWVeYhkzgN/JDR8deKBoD7Y+ljEjGZ0sosXVTvbc6RHirr2reNy1OXd6pJsQ+gqjk8VWFYmHrwBzW/n+uMPFiRwHB2I7ih8ciHFxIkd/3Omk5tCDV1t+2nNu5sxxpDFNx+huNhVT3/zMDz8usXC3ddaHBj1GHj/As08fwTS7Kt1HBTmyN29vdwAw+/wbwLVOJ3uAD1wi/dUH7Qei66PfyuRj4Ik9is+hglfbkbfR3cnZm7chlUWLdwmprtCohX4HUtlOcQjLYCu+fzGJH2QRKvP3UNz8bWk1qMxjGTOMThZ3kvgLI5AzFfo379UAAAAASUVORK5CYII='
const heartEyesEmojiURL =
'data:image/png;base64,' + unspecifiedBase64ImageURL
const heartEyesEmojiURL = 'data:image/png;base64,' + unspecifiedBase64ImageURL
describe('replay/transform', () => {
describe('validation', () => {
@ -23,7 +24,7 @@ describe('replay/transform', () => {
test('example of validating mobile meta event', () => {
const validData = {
data: {width: 1, height: 1},
data: { width: 1, height: 1 },
timestamp: 1,
type: EventType.Meta,
}
@ -40,7 +41,7 @@ describe('replay/transform', () => {
})
test('should be valid when...', () => {
expect(validateAgainstWebSchema({data: {}, timestamp: 12345, type: 0})).toBeTruthy()
expect(validateAgainstWebSchema({ data: {}, timestamp: 12345, type: 0 })).toBeTruthy()
})
})
})
@ -54,16 +55,16 @@ describe('replay/transform', () => {
expect(
posthogEEModule.mobileReplay?.transformToWeb([
{
data: {width: 300, height: 600},
data: { width: 300, height: 600 },
timestamp: 1,
type: 4,
},
{
data: {href: 'included when present', width: 300, height: 600},
data: { href: 'included when present', width: 300, height: 600 },
timestamp: 1,
type: 4,
},
{type: 9999},
{ type: 9999 },
{
type: 2,
data: {
@ -87,7 +88,7 @@ describe('replay/transform', () => {
test('can ignore unknown wireframe types', () => {
const unexpectedWireframeType = posthogEEModule.mobileReplay?.transformToWeb([
{
data: {screen: 'App Home Page', width: 300, height: 600},
data: { screen: 'App Home Page', width: 300, height: 600 },
timestamp: 1,
type: 4,
},
@ -114,14 +115,14 @@ describe('replay/transform', () => {
test('can short-circuit non-mobile full snapshot', () => {
const allWeb = posthogEEModule.mobileReplay?.transformToWeb([
{
data: {href: 'https://my-awesome.site', width: 300, height: 600},
data: { href: 'https://my-awesome.site', width: 300, height: 600 },
timestamp: 1,
type: 4,
},
{
type: 2,
data: {
node: {the: 'payload'},
node: { the: 'payload' },
},
timestamp: 1,
},
@ -235,7 +236,7 @@ describe('replay/transform', () => {
test('child wireframes are processed', () => {
const textEvent = posthogEEModule.mobileReplay?.transformToWeb([
{
data: {screen: 'App Home Page', width: 300, height: 600},
data: { screen: 'App Home Page', width: 300, height: 600 },
timestamp: 1,
type: 4,
},
@ -349,6 +350,11 @@ describe('replay/transform', () => {
expect(textEvent).toMatchSnapshot()
})
test('incremental mutations de-duplicate the tree', () => {
const conversion = posthogEEModule.mobileReplay?.transformEventToWeb(incrementalSnapshotJson)
expect(conversion).toMatchSnapshot()
})
test('omitting x and y is equivalent to setting them to 0', () => {
expect(
posthogEEModule.mobileReplay?.transformToWeb([
@ -486,7 +492,7 @@ describe('replay/transform', () => {
x: 0,
y: 0,
height: 30,
style: {backgroundImage: heartEyesEmojiURL,}
style: { backgroundImage: heartEyesEmojiURL },
},
{
id: 12346,
@ -494,7 +500,7 @@ describe('replay/transform', () => {
x: 0,
y: 0,
height: 30,
style: {backgroundImage: unspecifiedBase64ImageURL,}
style: { backgroundImage: unspecifiedBase64ImageURL },
},
{
id: 12346,
@ -502,7 +508,7 @@ describe('replay/transform', () => {
x: 0,
y: 0,
height: 30,
style: {backgroundImage: unspecifiedBase64ImageURL, backgroundSize: 'cover'}
style: { backgroundImage: unspecifiedBase64ImageURL, backgroundSize: 'cover' },
},
{
id: 12346,
@ -511,7 +517,7 @@ describe('replay/transform', () => {
y: 0,
height: 30,
// should be ignored
style: {backgroundImage: null,}
style: { backgroundImage: null },
},
],
},
@ -620,7 +626,7 @@ describe('replay/transform', () => {
height: 30,
type: 'input',
inputType: 'progress',
style: {bar: 'rating'},
style: { bar: 'rating' },
max: '12',
value: '6.5',
},
@ -636,7 +642,7 @@ describe('replay/transform', () => {
posthogEEModule.mobileReplay?.transformEventToWeb({
timestamp: 1,
type: EventType.Custom,
data: {tag: 'keyboard', payload: {open: true, height: 150}},
data: { tag: 'keyboard', payload: { open: true, height: 150 } },
})
).toMatchSnapshot()
})
@ -657,7 +663,7 @@ describe('replay/transform', () => {
height: 30,
type: 'input',
inputType: 'progress',
style: {bar: 'rating'},
style: { bar: 'rating' },
max: '12',
value: '6.5',
},
@ -675,7 +681,7 @@ describe('replay/transform', () => {
type: EventType.IncrementalSnapshot,
data: {
source: 0,
removes: [{parentId: 54321, id: 12345}],
removes: [{ parentId: 54321, id: 12345 }],
},
})
).toMatchSnapshot()
@ -699,7 +705,7 @@ describe('replay/transform', () => {
height: 30,
type: 'input',
inputType: 'progress',
style: {bar: 'rating'},
style: { bar: 'rating' },
max: '12',
value: '6.5',
},
@ -715,7 +721,7 @@ describe('replay/transform', () => {
posthogEEModule.mobileReplay?.transformEventToWeb({
timestamp: 1,
type: EventType.Custom,
data: {tag: 'keyboard', payload: {open: false}},
data: { tag: 'keyboard', payload: { open: false } },
})
).toMatchSnapshot()
})
@ -989,7 +995,7 @@ describe('replay/transform', () => {
height: 30,
type: 'input',
inputType: 'progress',
style: {bar: 'circular'},
style: { bar: 'circular' },
},
{
id: 12365,
@ -997,7 +1003,7 @@ describe('replay/transform', () => {
height: 30,
type: 'input',
inputType: 'progress',
style: {bar: 'horizontal'},
style: { bar: 'horizontal' },
},
{
id: 12365,
@ -1005,7 +1011,7 @@ describe('replay/transform', () => {
height: 30,
type: 'input',
inputType: 'progress',
style: {bar: 'horizontal'},
style: { bar: 'horizontal' },
value: 0.75,
},
{
@ -1014,7 +1020,7 @@ describe('replay/transform', () => {
height: 30,
type: 'input',
inputType: 'progress',
style: {bar: 'horizontal'},
style: { bar: 'horizontal' },
value: 0.75,
max: 2.5,
},

View File

@ -1,5 +1,6 @@
import {NodeType, serializedNodeWithId, wireframeStatusBar} from "./mobile.types";
import {NodeType, serializedNodeWithId, wireframeStatusBar} from "../mobile.types";
import {STATUS_BAR_ID} from "./transformers";
import {ConversionContext, ConversionResult} from "./types";
import {makeStylesString} from "./wireframeStyle";
function spacerDiv(idSequence: Generator<number>): serializedNodeWithId {
@ -19,10 +20,10 @@ function spacerDiv(idSequence: Generator<number>): serializedNodeWithId {
/**
* tricky: we need to accept children because that's the interface of converters, but we don't use them
*/
export function makeStatusBar(wireframe: wireframeStatusBar, _children: serializedNodeWithId[], timestamp: number, idSequence: Generator<number>): serializedNodeWithId {
const clockId = idSequence.next().value;
export function makeStatusBar(wireframe: wireframeStatusBar, _children: serializedNodeWithId[], context: ConversionContext): ConversionResult<serializedNodeWithId> {
const clockId = context.idSequence.next().value;
// convert the wireframe timestamp to a date time, then get just the hour and minute of the time from that
const clockTime = timestamp ? new Date(timestamp).toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'}) : ""
const clockTime = context.timestamp ? new Date(context.timestamp).toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'}) : ""
const clock: serializedNodeWithId = {
type: NodeType.Element,
tagName: 'div',
@ -34,12 +35,12 @@ export function makeStatusBar(wireframe: wireframeStatusBar, _children: serializ
{
type: NodeType.Text,
textContent: clockTime,
id: idSequence.next().value,
id: context.idSequence.next().value,
},
]
};
return {
return {result: {
type: NodeType.Element,
tagName: 'div',
attributes: {
@ -48,8 +49,8 @@ export function makeStatusBar(wireframe: wireframeStatusBar, _children: serializ
},
id: STATUS_BAR_ID,
childNodes: [
spacerDiv(idSequence),
spacerDiv(context.idSequence),
clock
],
}
}, context }
}

View File

@ -9,8 +9,8 @@ import {
mutationData,
removedNodeMutation,
} from '@rrweb/types'
import {captureMessage} from '@sentry/react'
import {isObject} from 'lib/utils'
import { captureMessage } from '@sentry/react'
import { isObject } from 'lib/utils'
import {
attributes,
@ -39,8 +39,9 @@ import {
wireframeSelect,
wireframeText,
wireframeToggle,
} from './mobile.types'
import {makeStatusBar} from "./status-bar";
} from '../mobile.types'
import { makeStatusBar } from './status-bar'
import { ConversionContext, ConversionResult, StyleOverride } from './types'
import {
makeBodyStyles,
makeColorStyles,
@ -50,7 +51,6 @@ import {
makeMinimalStyles,
makePositionStyles,
makeStylesString,
StyleOverride,
} from './wireframeStyle'
const BACKGROUND = '#f3f4ef'
@ -77,8 +77,7 @@ function* ids(): Generator<number> {
}
}
// TODO this is shared for the lifetime of the page, so a very, very long-lived session could exhaust the ids
const idSequence = ids()
let globalIdSequence = ids()
// there are some fixed ids that we need to use for fixed elements or artificial mutations
const DOCUMENT_ID = 1
@ -93,6 +92,10 @@ function isKeyboardEvent(x: unknown): x is keyboardEvent {
return isObject(x) && 'data' in x && isObject(x.data) && 'tag' in x.data && x.data.tag === 'keyboard'
}
export function _isPositiveInteger(id: unknown): id is number {
return typeof id === 'number' && id > 0 && id % 1 === 0
}
export const makeCustomEvent = (
mobileCustomEvent: (customEvent | keyboardEvent) & {
timestamp: number
@ -123,23 +126,28 @@ export const makeCustomEvent = (
: '100vw',
},
[],
styleOverride
{
timestamp: mobileCustomEvent.timestamp,
idSequence: globalIdSequence,
skippableNodes: new Set(),
styleOverride,
}
)
if (keyboardPlaceHolder) {
adds.push({
parentId: BODY_ID,
nextId: null,
node: keyboardPlaceHolder,
node: keyboardPlaceHolder.result,
})
// mutations seem not to want a tree of nodes to add
// so even though `keyboardPlaceholder` is a tree with content
// we have to add the text content as well
adds.push({
parentId: keyboardPlaceHolder.id,
parentId: keyboardPlaceHolder.result.id,
nextId: null,
node: {
type: NodeType.Text,
id: idSequence.next().value,
id: globalIdSequence.next().value,
textContent: 'keyboard',
},
})
@ -181,25 +189,32 @@ export const makeMetaEvent = (
timestamp: mobileMetaEvent.timestamp,
})
export function _isPositiveInteger(id: unknown): id is number {
return typeof id === 'number' && id > 0 && id % 1 === 0
}
function makeDivElement(wireframe: wireframeDiv, children: serializedNodeWithId[]): serializedNodeWithId | null {
const _id = _isPositiveInteger(wireframe.id) ? wireframe.id : idSequence.next().value
function makeDivElement(
wireframe: wireframeDiv,
children: serializedNodeWithId[],
context: ConversionContext
): ConversionResult<serializedNodeWithId> | null {
const _id = _isPositiveInteger(wireframe.id) ? wireframe.id : context.idSequence.next().value
return {
type: NodeType.Element,
tagName: 'div',
attributes: {
style: makeStylesString(wireframe) + 'overflow:hidden;white-space:nowrap;',
'data-rrweb-id': _id,
result: {
type: NodeType.Element,
tagName: 'div',
attributes: {
style: makeStylesString(wireframe) + 'overflow:hidden;white-space:nowrap;',
'data-rrweb-id': _id,
},
id: _id,
childNodes: children,
},
id: _id,
childNodes: children,
context,
}
}
function makeTextElement(wireframe: wireframeText, children: serializedNodeWithId[]): serializedNodeWithId | null {
function makeTextElement(
wireframe: wireframeText,
children: serializedNodeWithId[],
context: ConversionContext
): ConversionResult<serializedNodeWithId> | null {
if (wireframe.type !== 'text') {
console.error('Passed incorrect wireframe type to makeTextElement')
return null
@ -207,92 +222,109 @@ function makeTextElement(wireframe: wireframeText, children: serializedNodeWithI
// because we might have to style the text, we always wrap it in a div
// and apply styles to that
const id = idSequence.next().value
const id = context.idSequence.next().value
return {
type: NodeType.Element,
tagName: 'div',
attributes: {
style: makeStylesString(wireframe) + 'overflow:hidden;white-space:nowrap;',
'data-rrweb-id': wireframe.id,
},
id: wireframe.id,
childNodes: [
{
type: NodeType.Text,
textContent: wireframe.text,
// since the text node is wrapped, we assign it a synthetic id
id: id,
result: {
type: NodeType.Element,
tagName: 'div',
attributes: {
style: makeStylesString(wireframe) + 'overflow:hidden;white-space:nowrap;',
'data-rrweb-id': wireframe.id,
},
...children,
],
id: wireframe.id,
childNodes: [
{
type: NodeType.Text,
textContent: wireframe.text,
// since the text node is wrapped, we assign it a synthetic id
id: id,
},
...children,
],
},
context,
}
}
function makeWebViewElement(wireframe: wireframe, children: serializedNodeWithId[]): serializedNodeWithId | null {
function makeWebViewElement(
wireframe: wireframe,
children: serializedNodeWithId[],
context: ConversionContext
): ConversionResult<serializedNodeWithId> | null {
const labelledWireframe: wireframePlaceholder = { ...wireframe } as wireframePlaceholder
if ('url' in wireframe) {
labelledWireframe.label = wireframe.url
}
return makePlaceholderElement(labelledWireframe, children)
return makePlaceholderElement(labelledWireframe, children, context)
}
function makePlaceholderElement(
wireframe: wireframe,
children: serializedNodeWithId[],
styleOverride?: StyleOverride
): serializedNodeWithId | null {
context: ConversionContext
): ConversionResult<serializedNodeWithId> | null {
const txt = 'label' in wireframe && wireframe.label ? wireframe.label : wireframe.type || 'PLACEHOLDER'
return {
type: NodeType.Element,
tagName: 'div',
attributes: {
style: makeStylesString(wireframe, {
verticalAlign: 'center',
horizontalAlign: 'center',
backgroundColor: wireframe.style?.backgroundColor || BACKGROUND,
color: wireframe.style?.color || FOREGROUND,
...styleOverride,
}),
'data-rrweb-id': wireframe.id,
},
id: wireframe.id,
childNodes: [
{
type: NodeType.Text,
// since the text node is wrapped, we assign it a synthetic id
id: idSequence.next().value,
textContent: txt,
result: {
type: NodeType.Element,
tagName: 'div',
attributes: {
style: makeStylesString(wireframe, {
verticalAlign: 'center',
horizontalAlign: 'center',
backgroundColor: wireframe.style?.backgroundColor || BACKGROUND,
color: wireframe.style?.color || FOREGROUND,
...context.styleOverride,
}),
'data-rrweb-id': wireframe.id,
},
...children,
],
id: wireframe.id,
childNodes: [
{
type: NodeType.Text,
// since the text node is wrapped, we assign it a synthetic id
id: context.idSequence.next().value,
textContent: txt,
},
...children,
],
},
context,
}
}
export function dataURIOrPNG(src: string):string {
export function dataURIOrPNG(src: string): string {
if (!src.startsWith('data:image/')) {
return 'data:image/png;base64,' + src
}
return src;
return src
}
function makeImageElement(wireframe: wireframeImage, children: serializedNodeWithId[]): serializedNodeWithId | null {
function makeImageElement(
wireframe: wireframeImage,
children: serializedNodeWithId[],
context: ConversionContext
): ConversionResult<serializedNodeWithId> | null {
if (!wireframe.base64) {
return makePlaceholderElement(wireframe, children)
return makePlaceholderElement(wireframe, children, context)
}
const src = dataURIOrPNG(wireframe.base64);
const src = dataURIOrPNG(wireframe.base64)
return {
type: NodeType.Element,
tagName: 'img',
attributes: {
src: src,
width: wireframe.width,
height: wireframe.height,
style: makeStylesString(wireframe),
'data-rrweb-id': wireframe.id,
result: {
type: NodeType.Element,
tagName: 'img',
attributes: {
src: src,
width: wireframe.width,
height: wireframe.height,
style: makeStylesString(wireframe),
'data-rrweb-id': wireframe.id,
},
id: wireframe.id,
childNodes: children,
},
id: wireframe.id,
childNodes: children,
context,
}
}
@ -354,7 +386,11 @@ function inputAttributes<T extends wireframeInputComponent>(wireframe: T): attri
}
}
function makeButtonElement(wireframe: wireframeButton, children: serializedNodeWithId[]): serializedNodeWithId | null {
function makeButtonElement(
wireframe: wireframeButton,
children: serializedNodeWithId[],
context: ConversionContext
): ConversionResult<serializedNodeWithId> | null {
const buttonText: textNode | null = wireframe.value
? {
type: NodeType.Text,
@ -363,44 +399,68 @@ function makeButtonElement(wireframe: wireframeButton, children: serializedNodeW
: null
return {
type: NodeType.Element,
tagName: 'button',
attributes: inputAttributes(wireframe),
id: wireframe.id,
childNodes: buttonText ? [{ ...buttonText, id: idSequence.next().value }, ...children] : children,
}
}
function makeSelectOptionElement(option: string, selected: boolean): serializedNodeWithId {
const optionId = idSequence.next().value
return {
type: NodeType.Element,
tagName: 'option',
attributes: {
...(selected ? { selected: selected } : {}),
'data-rrweb-id': optionId,
result: {
type: NodeType.Element,
tagName: 'button',
attributes: inputAttributes(wireframe),
id: wireframe.id,
childNodes: buttonText ? [{ ...buttonText, id: context.idSequence.next().value }, ...children] : children,
},
id: optionId,
childNodes: [
{
type: NodeType.Text,
textContent: option,
id: idSequence.next().value,
},
],
context,
}
}
function makeSelectElement(wireframe: wireframeSelect, children: serializedNodeWithId[]): serializedNodeWithId | null {
function makeSelectOptionElement(
option: string,
selected: boolean,
context: ConversionContext
): ConversionResult<serializedNodeWithId> {
const optionId = context.idSequence.next().value
return {
type: NodeType.Element,
tagName: 'select',
attributes: inputAttributes(wireframe),
id: wireframe.id,
childNodes: [
...(wireframe.options?.map((option) => makeSelectOptionElement(option, wireframe.value === option)) || []),
...children,
],
result: {
type: NodeType.Element,
tagName: 'option',
attributes: {
...(selected ? { selected: selected } : {}),
'data-rrweb-id': optionId,
},
id: optionId,
childNodes: [
{
type: NodeType.Text,
textContent: option,
id: context.idSequence.next().value,
},
],
},
context,
}
}
function makeSelectElement(
wireframe: wireframeSelect,
children: serializedNodeWithId[],
context: ConversionContext
): ConversionResult<serializedNodeWithId> | null {
const selectOptions: serializedNodeWithId[] = []
if (wireframe.options) {
let optionContext = context
for (let i = 0; i < wireframe.options.length; i++) {
const option = wireframe.options[i]
const conversion = makeSelectOptionElement(option, wireframe.value === option, optionContext)
selectOptions.push(conversion.result)
optionContext = conversion.context
}
}
return {
result: {
type: NodeType.Element,
tagName: 'select',
attributes: inputAttributes(wireframe),
id: wireframe.id,
childNodes: [...selectOptions, ...children],
},
context,
}
}
@ -422,25 +482,29 @@ function groupRadioButtons(children: serializedNodeWithId[], radioGroupName: str
function makeRadioGroupElement(
wireframe: wireframeRadioGroup,
children: serializedNodeWithId[]
): serializedNodeWithId | null {
children: serializedNodeWithId[],
context: ConversionContext
): ConversionResult<serializedNodeWithId> | null {
const radioGroupName = 'radio_group_' + wireframe.id
return {
type: NodeType.Element,
tagName: 'div',
attributes: {
style: makeStylesString(wireframe),
'data-rrweb-id': wireframe.id,
result: {
type: NodeType.Element,
tagName: 'div',
attributes: {
style: makeStylesString(wireframe),
'data-rrweb-id': wireframe.id,
},
id: wireframe.id,
childNodes: groupRadioButtons(children, radioGroupName),
},
id: wireframe.id,
childNodes: groupRadioButtons(children, radioGroupName),
context,
}
}
function makeStar(title: string, path: string): serializedNodeWithId {
const svgId = idSequence.next().value
const titleId = idSequence.next().value
const pathId = idSequence.next().value
function makeStar(title: string, path: string, context: ConversionContext): serializedNodeWithId {
const svgId = context.idSequence.next().value
const titleId = context.idSequence.next().value
const pathId = context.idSequence.next().value
return {
type: NodeType.Element,
tagName: 'svg',
@ -465,7 +529,7 @@ function makeStar(title: string, path: string): serializedNodeWithId {
{
type: NodeType.Text,
textContent: title,
id: idSequence.next().value,
id: context.idSequence.next().value,
},
],
},
@ -484,33 +548,40 @@ function makeStar(title: string, path: string): serializedNodeWithId {
}
}
function filledStar(): serializedNodeWithId {
function filledStar(context: ConversionContext): serializedNodeWithId {
return makeStar(
'filled star',
'M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z'
'M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z',
context
)
}
function halfStar(): serializedNodeWithId {
function halfStar(context: ConversionContext): serializedNodeWithId {
return makeStar(
'half-filled star',
'M12,15.4V6.1L13.71,10.13L18.09,10.5L14.77,13.39L15.76,17.67M22,9.24L14.81,8.63L12,2L9.19,8.63L2,9.24L7.45,13.97L5.82,21L12,17.27L18.18,21L16.54,13.97L22,9.24Z'
'M12,15.4V6.1L13.71,10.13L18.09,10.5L14.77,13.39L15.76,17.67M22,9.24L14.81,8.63L12,2L9.19,8.63L2,9.24L7.45,13.97L5.82,21L12,17.27L18.18,21L16.54,13.97L22,9.24Z',
context
)
}
function emptyStar(): serializedNodeWithId {
function emptyStar(context: ConversionContext): serializedNodeWithId {
return makeStar(
'empty star',
'M12,15.39L8.24,17.66L9.23,13.38L5.91,10.5L10.29,10.13L12,6.09L13.71,10.13L18.09,10.5L14.77,13.38L15.76,17.66M22,9.24L14.81,8.63L12,2L9.19,8.63L2,9.24L7.45,13.97L5.82,21L12,17.27L18.18,21L16.54,13.97L22,9.24Z'
'M12,15.39L8.24,17.66L9.23,13.38L5.91,10.5L10.29,10.13L12,6.09L13.71,10.13L18.09,10.5L14.77,13.38L15.76,17.66M22,9.24L14.81,8.63L12,2L9.19,8.63L2,9.24L7.45,13.97L5.82,21L12,17.27L18.18,21L16.54,13.97L22,9.24Z',
context
)
}
function makeRatingBar(wireframe: wireframeProgress, children: serializedNodeWithId[]): serializedNodeWithId | null {
function makeRatingBar(
wireframe: wireframeProgress,
children: serializedNodeWithId[],
context: ConversionContext
): ConversionResult<serializedNodeWithId> | null {
// max is the number of stars... and value is the number of stars to fill
// deliberate double equals, because we want to allow null and undefined
if (wireframe.value == null || wireframe.max == null) {
return makePlaceholderElement(wireframe, children)
return makePlaceholderElement(wireframe, children, context)
}
const numberOfFilledStars = Math.floor(wireframe.value)
@ -519,15 +590,15 @@ function makeRatingBar(wireframe: wireframeProgress, children: serializedNodeWit
const filledStars = Array(numberOfFilledStars)
.fill(undefined)
.map(() => filledStar())
.map(() => filledStar(context))
const halfStars = Array(numberOfHalfStars)
.fill(undefined)
.map(() => halfStar())
.map(() => halfStar(context))
const emptyStars = Array(numberOfEmptyStars)
.fill(undefined)
.map(() => emptyStar())
.map(() => emptyStar(context))
const ratingBarId = idSequence.next().value
const ratingBarId = context.idSequence.next().value
const ratingBar = {
type: NodeType.Element,
tagName: 'div',
@ -542,21 +613,25 @@ function makeRatingBar(wireframe: wireframeProgress, children: serializedNodeWit
} as serializedNodeWithId
return {
type: NodeType.Element,
tagName: 'div',
attributes: {
style: makeStylesString(wireframe),
'data-rrweb-id': wireframe.id,
result: {
type: NodeType.Element,
tagName: 'div',
attributes: {
style: makeStylesString(wireframe),
'data-rrweb-id': wireframe.id,
},
id: wireframe.id,
childNodes: [ratingBar, ...children],
},
id: wireframe.id,
childNodes: [ratingBar, ...children],
context,
}
}
function makeProgressElement(
wireframe: wireframeProgress,
children: serializedNodeWithId[]
): serializedNodeWithId | null {
children: serializedNodeWithId[],
context: ConversionContext
): ConversionResult<serializedNodeWithId> | null {
if (wireframe.style?.bar === 'circular') {
// value needs to be expressed as a number between 0 and 100
const max = wireframe.max || 1
@ -583,61 +658,67 @@ function makeProgressElement(
attributes: {
type: 'text/css',
},
id: idSequence.next().value,
id: context.idSequence.next().value,
childNodes: [
{
type: NodeType.Text,
textContent: `@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }`,
id: idSequence.next().value,
id: context.idSequence.next().value,
},
],
},
]
const wrappingDivId = idSequence.next().value
const wrappingDivId = context.idSequence.next().value
return {
type: NodeType.Element,
tagName: 'div',
attributes: {
style: makeMinimalStyles(wireframe),
'data-rrweb-id': wireframe.id,
},
id: wireframe.id,
childNodes: [
{
type: NodeType.Element,
tagName: 'div',
attributes: {
// with no provided value we render a spinner
style: _isPositiveInteger(value)
? makeDeterminateProgressStyles(wireframe, styleOverride)
: makeIndeterminateProgressStyles(wireframe, styleOverride),
'data-rrweb-id': wrappingDivId,
},
id: wrappingDivId,
childNodes: stylingChildren,
result: {
type: NodeType.Element,
tagName: 'div',
attributes: {
style: makeMinimalStyles(wireframe),
'data-rrweb-id': wireframe.id,
},
...children,
],
id: wireframe.id,
childNodes: [
{
type: NodeType.Element,
tagName: 'div',
attributes: {
// with no provided value we render a spinner
style: _isPositiveInteger(value)
? makeDeterminateProgressStyles(wireframe, styleOverride)
: makeIndeterminateProgressStyles(wireframe, styleOverride),
'data-rrweb-id': wrappingDivId,
},
id: wrappingDivId,
childNodes: stylingChildren,
},
...children,
],
},
context,
}
} else if (wireframe.style?.bar === 'rating') {
return makeRatingBar(wireframe, children)
return makeRatingBar(wireframe, children, context)
} else {
return {
type: NodeType.Element,
tagName: 'progress',
attributes: inputAttributes(wireframe),
id: wireframe.id,
childNodes: children,
result: {
type: NodeType.Element,
tagName: 'progress',
attributes: inputAttributes(wireframe),
id: wireframe.id,
childNodes: children,
},
context,
}
}
}
function makeToggleParts(wireframe: wireframeToggle): serializedNodeWithId[] {
function makeToggleParts(wireframe: wireframeToggle, context: ConversionContext): serializedNodeWithId[] {
const togglePosition = wireframe.checked ? 'right' : 'left'
const defaultColor = wireframe.checked ? '#1d4aff' : BACKGROUND
const sliderPartId = idSequence.next().value
const handlePartId = idSequence.next().value
const sliderPartId = context.idSequence.next().value
const handlePartId = context.idSequence.next().value
return [
{
type: NodeType.Element,
@ -670,88 +751,106 @@ function makeToggleParts(wireframe: wireframeToggle): serializedNodeWithId[] {
]
}
function makeToggleElement(wireframe: wireframeToggle): (elementNode & { id: number }) | null {
function makeToggleElement(
wireframe: wireframeToggle,
context: ConversionContext
): ConversionResult<
elementNode & {
id: number
}
> | null {
const isLabelled = 'label' in wireframe
const wrappingDivId = idSequence.next().value
const wrappingDivId = context.idSequence.next().value
return {
type: NodeType.Element,
tagName: 'div',
attributes: {
// if labelled take up available space, otherwise use provided positioning
style: isLabelled ? `height:100%;flex:1` : makePositionStyles(wireframe),
'data-rrweb-id': wireframe.id,
},
id: wireframe.id,
childNodes: [
{
type: NodeType.Element,
tagName: 'div',
attributes: {
// relative position, fills parent
style: 'position:relative;width:100%;height:100%;',
'data-rrweb-id': wrappingDivId,
},
id: wrappingDivId,
childNodes: makeToggleParts(wireframe),
result: {
type: NodeType.Element,
tagName: 'div',
attributes: {
// if labelled take up available space, otherwise use provided positioning
style: isLabelled ? `height:100%;flex:1` : makePositionStyles(wireframe),
'data-rrweb-id': wireframe.id,
},
],
id: wireframe.id,
childNodes: [
{
type: NodeType.Element,
tagName: 'div',
attributes: {
// relative position, fills parent
style: 'position:relative;width:100%;height:100%;',
'data-rrweb-id': wrappingDivId,
},
id: wrappingDivId,
childNodes: makeToggleParts(wireframe, context),
},
],
},
context,
}
}
function makeLabelledInput(
wireframe: wireframeCheckBox | wireframeRadio | wireframeToggle,
theInputElement: serializedNodeWithId
): serializedNodeWithId {
theInputElement: serializedNodeWithId,
context: ConversionContext
): ConversionResult<serializedNodeWithId> {
const theLabel: serializedNodeWithId = {
type: NodeType.Text,
textContent: wireframe.label || '',
id: idSequence.next().value,
id: context.idSequence.next().value,
}
const orderedChildren = wireframe.inputType === 'toggle' ? [theLabel, theInputElement] : [theInputElement, theLabel]
const labelId = idSequence.next().value
const labelId = context.idSequence.next().value
return {
type: NodeType.Element,
tagName: 'label',
attributes: {
style: makeStylesString(wireframe),
'data-rrweb-id': labelId,
result: {
type: NodeType.Element,
tagName: 'label',
attributes: {
style: makeStylesString(wireframe),
'data-rrweb-id': labelId,
},
id: labelId,
childNodes: orderedChildren,
},
id: labelId,
childNodes: orderedChildren,
context,
}
}
function makeInputElement(
wireframe: wireframeInputComponent,
children: serializedNodeWithId[]
): serializedNodeWithId | null {
children: serializedNodeWithId[],
context: ConversionContext
): ConversionResult<serializedNodeWithId> | null {
if (!wireframe.inputType) {
return null
}
if (wireframe.inputType === 'button') {
return makeButtonElement(wireframe, children)
return makeButtonElement(wireframe, children, context)
}
if (wireframe.inputType === 'select') {
return makeSelectElement(wireframe, children)
return makeSelectElement(wireframe, children, context)
}
if (wireframe.inputType === 'progress') {
return makeProgressElement(wireframe, children)
return makeProgressElement(wireframe, children, context)
}
const theInputElement: serializedNodeWithId | null =
const theInputElement: ConversionResult<serializedNodeWithId> | null =
wireframe.inputType === 'toggle'
? makeToggleElement(wireframe)
? makeToggleElement(wireframe, context)
: {
type: NodeType.Element,
tagName: 'input',
attributes: inputAttributes(wireframe),
id: wireframe.id,
childNodes: children,
result: {
type: NodeType.Element,
tagName: 'input',
attributes: inputAttributes(wireframe),
id: wireframe.id,
childNodes: children,
},
context,
}
if (!theInputElement) {
@ -759,45 +858,48 @@ function makeInputElement(
}
if ('label' in wireframe) {
return makeLabelledInput(wireframe, theInputElement)
return makeLabelledInput(wireframe, theInputElement.result, theInputElement.context)
} else {
return {
...theInputElement,
attributes: {
...theInputElement.attributes,
// when labelled no styles are needed, when un-labelled as here - we add the styling in.
style: makeStylesString(wireframe),
},
}
// when labelled no styles are needed, when un-labelled as here - we add the styling in.
;(theInputElement.result as elementNode).attributes.style = makeStylesString(wireframe)
return theInputElement
}
}
function makeRectangleElement(
wireframe: wireframeRectangle,
children: serializedNodeWithId[]
): serializedNodeWithId | null {
children: serializedNodeWithId[],
context: ConversionContext
): ConversionResult<serializedNodeWithId> | null {
return {
type: NodeType.Element,
tagName: 'div',
attributes: {
style: makeStylesString(wireframe),
'data-rrweb-id': wireframe.id,
result: {
type: NodeType.Element,
tagName: 'div',
attributes: {
style: makeStylesString(wireframe),
'data-rrweb-id': wireframe.id,
},
id: wireframe.id,
childNodes: children,
},
id: wireframe.id,
childNodes: children,
context,
}
}
function chooseConverter<T extends wireframe>(
wireframe: T
): (wireframe: T, children: serializedNodeWithId[], timestamp?: number, idSequence?: Generator<number>) => serializedNodeWithId | null {
): (
wireframe: T,
children: serializedNodeWithId[],
context: ConversionContext
) => ConversionResult<serializedNodeWithId> | null {
// in theory type is always present
// but since this is coming over the wire we can't really be sure,
// and so we default to div
const converterType: MobileNodeType = wireframe.type || 'div'
const converterMapping: Record<
MobileNodeType,
(wireframe: T, children: serializedNodeWithId[]) => serializedNodeWithId | null
(wireframe: T, children: serializedNodeWithId[]) => ConversionResult<serializedNodeWithId> | null
> = {
// KLUDGE: TS can't tell that the wireframe type of each function is safe based on the converter type
text: makeTextElement as any,
@ -815,24 +917,41 @@ function chooseConverter<T extends wireframe>(
return converterMapping[converterType]
}
function convertWireframe(wireframe: wireframe, timestamp?: number, idSequence?: Generator<number>): serializedNodeWithId | null {
const children = convertWireframesFor(wireframe.childWireframes, timestamp)
const converter = chooseConverter(wireframe)
return converter?.(wireframe, children, timestamp, idSequence) || null
}
function convertWireframesFor(wireframes: wireframe[] | undefined, timestamp?: number): serializedNodeWithId[] {
if (!wireframes) {
return []
function convertWireframe(
wireframe: wireframe,
context: ConversionContext
): ConversionResult<serializedNodeWithId> | null {
if (context.skippableNodes?.has(wireframe.id)) {
return null
}
return wireframes.reduce((acc, wireframe) => {
const convertedEl = convertWireframe(wireframe, timestamp, idSequence)
if (convertedEl !== null) {
acc.push(convertedEl)
const children = convertWireframesFor(wireframe.childWireframes, context)
const converter = chooseConverter(wireframe)
// every wireframe comes through this converter,
// so to track which ones we want to skip,
// we can add them here
context.skippableNodes?.add(wireframe.id)
const converted = converter?.(wireframe, children.result, children.context)
return converted || null
}
function convertWireframesFor(
wireframes: wireframe[] | undefined,
context: ConversionContext
): ConversionResult<serializedNodeWithId[]> {
if (!wireframes) {
return { result: [], context }
}
const result: serializedNodeWithId[] = []
for (const wireframe of wireframes) {
const converted = convertWireframe(wireframe, context)
if (converted) {
result.push(converted.result)
context = converted.context
}
return acc
}, [] as serializedNodeWithId[])
}
return { result, context }
}
function isMobileIncrementalSnapshotEvent(x: unknown): x is MobileIncrementalSnapshotEvent {
@ -854,8 +973,8 @@ function isMobileIncrementalSnapshotEvent(x: unknown): x is MobileIncrementalSna
return hasMutationSource && (hasAddedWireframe || hasUpdatedWireframe)
}
function makeIncrementalAdd(add: MobileNodeMutation): addedNodeMutation[] | null {
const converted = convertWireframe(add.wireframe, undefined, idSequence)
function makeIncrementalAdd(add: MobileNodeMutation, context: ConversionContext): addedNodeMutation[] | null {
const converted = convertWireframe(add.wireframe, context)
if (!converted) {
return null
}
@ -863,7 +982,7 @@ function makeIncrementalAdd(add: MobileNodeMutation): addedNodeMutation[] | null
const addition: addedNodeMutation = {
parentId: add.parentId,
nextId: null,
node: converted,
node: converted.result,
}
const adds: addedNodeMutation[] = []
if (addition) {
@ -964,17 +1083,28 @@ export const makeIncrementalEvent = (
const adds: addedNodeMutation[] = []
const removes: removedNodeMutation[] = mobileEvent.data.removes || []
if ('adds' in mobileEvent.data && Array.isArray(mobileEvent.data.adds)) {
const addsContext = {
timestamp: mobileEvent.timestamp,
idSequence: globalIdSequence,
skippableNodes: new Set<number>(),
}
mobileEvent.data.adds.forEach((add) => {
makeIncrementalAdd(add)?.forEach((x) => adds.push(x))
makeIncrementalAdd(add, addsContext)?.forEach((x) => adds.push(x))
})
}
if ('updates' in mobileEvent.data && Array.isArray(mobileEvent.data.updates)) {
const updatesContext = {
timestamp: mobileEvent.timestamp,
idSequence: globalIdSequence,
skippableNodes: new Set<number>(),
}
mobileEvent.data.updates.forEach((update) => {
const removal = makeIncrementalRemoveForUpdate(update)
if (removal) {
removes.push(removal)
}
makeIncrementalAdd(update)?.forEach((x) => adds.push(x))
makeIncrementalAdd(update, updatesContext)?.forEach((x) => adds.push(x))
})
}
@ -1000,6 +1130,9 @@ export const makeFullEvent = (
timestamp: number
delay?: number
} => {
// we can restart the id sequence on each full snapshot
globalIdSequence = ids()
if (!('wireframes' in mobileEvent.data)) {
return mobileEvent as unknown as fullSnapshotEvent & {
timestamp: number
@ -1039,7 +1172,11 @@ export const makeFullEvent = (
tagName: 'body',
attributes: { style: makeBodyStyles(), 'data-rrweb-id': BODY_ID },
id: BODY_ID,
childNodes: convertWireframesFor(mobileEvent.data.wireframes, mobileEvent.timestamp) || [],
childNodes:
convertWireframesFor(mobileEvent.data.wireframes, {
timestamp: mobileEvent.timestamp,
idSequence: globalIdSequence,
}).result || [],
},
],
},

View File

@ -0,0 +1,25 @@
import { MobileStyles } from '../mobile.types'
export interface ConversionResult<T> {
result: T
context: ConversionContext
}
export interface ConversionContext {
timestamp: number
idSequence: Generator<number>
// in some contexts we want to be able to skip nodes that have already been processed
// for example updates are processed as a remove and then an add of the whole tree
// this means the mobile app doesn't have to store and diff the tree
// it can just send the whole thing over
// but multiple nearby updates can result in the same node being present
// in the tree multiple times
// we track which nodes have been processed to avoid adding them multiple times
skippableNodes?: Set<number>
styleOverride?: StyleOverride
}
// StyleOverride is defined here and not in the schema
// because these are overrides that the transformer is allowed to make
// not that clients are allowed to request
export type StyleOverride = MobileStyles & { bottom?: true }

View File

@ -1,10 +1,6 @@
import { MobileStyles, wireframe, wireframeProgress } from './mobile.types'
import { wireframe, wireframeProgress } from '../mobile.types'
import {dataURIOrPNG} from "./transformers";
// StyleOverride is defined here and not in the schema
// because these are overrides that the transformer is allowed to make
// not that clients are allowed to request
export type StyleOverride = MobileStyles & { bottom?: true }
import {StyleOverride} from "./types";
function isNumber(candidate: unknown): candidate is number {
return typeof candidate === 'number'

View File

@ -319,6 +319,10 @@
"eslint -c .eslintrc.js --fix",
"prettier --write"
],
"ee/frontend/**/*.{js,jsx,mjs,ts,tsx}": [
"eslint -c .eslintrc.js --fix",
"prettier --write"
],
"plugin-server/**/*.{js,jsx,mjs,ts,tsx}": [
"pnpm --dir plugin-server exec eslint --fix",
"pnpm --dir plugin-server exec prettier --write"