Compare commits

...

2 Commits

6 changed files with 75 additions and 16 deletions

View File

@@ -11,6 +11,7 @@ import {
handleHiddenFields, handleHiddenFields,
shouldDisplayBasedOnPercentage, shouldDisplayBasedOnPercentage,
} from "@/lib/common/utils"; } from "@/lib/common/utils";
import { UpdateQueue } from "@/lib/user/update-queue";
import { type TEnvironmentStateSurvey, type TUserState } from "@/types/config"; import { type TEnvironmentStateSurvey, type TUserState } from "@/types/config";
import { type TTrackProperties } from "@/types/survey"; import { type TTrackProperties } from "@/types/survey";
@@ -60,6 +61,24 @@ export const renderWidget = async (
setIsSurveyRunning(true); setIsSurveyRunning(true);
// Wait for pending user identification to complete before rendering
const updateQueue = UpdateQueue.getInstance();
if (updateQueue.hasPendingWork()) {
logger.debug("Waiting for pending user identification before rendering survey");
const identificationSucceeded = await updateQueue.waitForPendingWork();
if (!identificationSucceeded) {
const hasSegmentFilters = Array.isArray(survey.segment?.filters) && survey.segment.filters.length > 0;
if (hasSegmentFilters) {
logger.debug("User identification failed. Skipping survey with segment filters.");
setIsSurveyRunning(false);
return;
}
logger.debug("User identification failed but survey has no segment filters. Proceeding.");
}
}
if (survey.delay) { if (survey.delay) {
logger.debug(`Delaying survey "${survey.name}" by ${survey.delay.toString()} seconds.`); logger.debug(`Delaying survey "${survey.name}" by ${survey.delay.toString()} seconds.`);
} }

View File

@@ -8,7 +8,9 @@ export class UpdateQueue {
private static instance: UpdateQueue | null = null; private static instance: UpdateQueue | null = null;
private updates: TUpdates | null = null; private updates: TUpdates | null = null;
private debounceTimeout: NodeJS.Timeout | null = null; private debounceTimeout: NodeJS.Timeout | null = null;
private pendingFlush: Promise<void> | null = null;
private readonly DEBOUNCE_DELAY = 500; private readonly DEBOUNCE_DELAY = 500;
private readonly PENDING_WORK_TIMEOUT = 5000;
private constructor() {} private constructor() {}
@@ -63,17 +65,45 @@ export class UpdateQueue {
return !this.updates; return !this.updates;
} }
public hasPendingWork(): boolean {
return this.updates !== null || this.pendingFlush !== null;
}
public async waitForPendingWork(): Promise<boolean> {
if (!this.hasPendingWork()) return true;
const flush = this.pendingFlush ?? this.processUpdates();
try {
const succeeded = await Promise.race([
flush.then(() => true as const),
new Promise<false>((resolve) => {
setTimeout(() => {
resolve(false);
}, this.PENDING_WORK_TIMEOUT);
}),
]);
return succeeded;
} catch {
return false;
}
}
public async processUpdates(): Promise<void> { public async processUpdates(): Promise<void> {
const logger = Logger.getInstance(); const logger = Logger.getInstance();
if (!this.updates) { if (!this.updates) {
return; return;
} }
// If a flush is already in flight, reuse it instead of creating a new promise
if (this.pendingFlush) {
return this.pendingFlush;
}
if (this.debounceTimeout) { if (this.debounceTimeout) {
clearTimeout(this.debounceTimeout); clearTimeout(this.debounceTimeout);
} }
return new Promise((resolve, reject) => { const flushPromise = new Promise<void>((resolve, reject) => {
const handler = async (): Promise<void> => { const handler = async (): Promise<void> => {
try { try {
let currentUpdates = { ...this.updates }; let currentUpdates = { ...this.updates };
@@ -147,8 +177,10 @@ export class UpdateQueue {
} }
this.clearUpdates(); this.clearUpdates();
this.pendingFlush = null;
resolve(); resolve();
} catch (error: unknown) { } catch (error: unknown) {
this.pendingFlush = null;
logger.error( logger.error(
`Failed to process updates: ${error instanceof Error ? error.message : "Unknown error"}` `Failed to process updates: ${error instanceof Error ? error.message : "Unknown error"}`
); );
@@ -158,5 +190,8 @@ export class UpdateQueue {
this.debounceTimeout = setTimeout(() => void handler(), this.DEBOUNCE_DELAY); this.debounceTimeout = setTimeout(() => void handler(), this.DEBOUNCE_DELAY);
}); });
this.pendingFlush = flushPromise;
return flushPromise;
} }
} }

View File

@@ -1,4 +1,4 @@
import DOMPurify from "isomorphic-dompurify"; import { sanitize } from "isomorphic-dompurify";
import * as React from "react"; import * as React from "react";
import { cn, stripInlineStyles } from "@/lib/utils"; import { cn, stripInlineStyles } from "@/lib/utils";
@@ -39,7 +39,7 @@ function Label({
const isHtml = childrenString ? isValidHTML(strippedContent) : false; const isHtml = childrenString ? isValidHTML(strippedContent) : false;
const safeHtml = const safeHtml =
isHtml && strippedContent isHtml && strippedContent
? DOMPurify.sanitize(strippedContent, { ? sanitize(strippedContent, {
ADD_ATTR: ["target"], ADD_ATTR: ["target"],
FORBID_ATTR: ["style"], FORBID_ATTR: ["style"],
}) })

View File

@@ -1,5 +1,5 @@
import { type ClassValue, clsx } from "clsx"; import { type ClassValue, clsx } from "clsx";
import DOMPurify from "isomorphic-dompurify"; import { sanitize } from "isomorphic-dompurify";
import { extendTailwindMerge } from "tailwind-merge"; import { extendTailwindMerge } from "tailwind-merge";
const twMerge = extendTailwindMerge({ const twMerge = extendTailwindMerge({
@@ -27,14 +27,16 @@ export function cn(...inputs: ClassValue[]): string {
export const stripInlineStyles = (html: string): string => { export const stripInlineStyles = (html: string): string => {
if (!html) return html; if (!html) return html;
// Use DOMPurify to safely remove style attributes // Pre-strip style attributes from the raw string BEFORE DOMPurify parses it.
// This is more secure than regex-based approaches and handles edge cases properly // DOMPurify internally uses innerHTML to parse HTML, which triggers CSP
return DOMPurify.sanitize(html, { // `style-src` violations at parse time — before FORBID_ATTR can strip them.
// The regex is O(n) safe: [^"]* and [^']* are negated classes bounded by
// fixed quote delimiters, so no backtracking can occur.
const preStripped = html.replaceAll(/ style="[^"]*"| style='[^']*'/gi, "");
return sanitize(preStripped, {
FORBID_ATTR: ["style"], FORBID_ATTR: ["style"],
// Preserve the target attribute (e.g. target="_blank" on links) which is not
// in DOMPurify's default allow-list but is explicitly required downstream.
ADD_ATTR: ["target"], ADD_ATTR: ["target"],
// Keep other attributes and tags as-is, only remove style attributes
KEEP_CONTENT: true, KEEP_CONTENT: true,
}); });
}; };

View File

@@ -4,6 +4,7 @@
"baseUrl": ".", "baseUrl": ".",
"isolatedModules": true, "isolatedModules": true,
"jsx": "react-jsx", "jsx": "react-jsx",
"lib": ["DOM", "DOM.Iterable", "ES2020", "ES2021.String"],
"noEmit": true, "noEmit": true,
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": ["./src/*"]

View File

@@ -10,14 +10,16 @@ import DOMPurify from "isomorphic-dompurify";
export const stripInlineStyles = (html: string): string => { export const stripInlineStyles = (html: string): string => {
if (!html) return html; if (!html) return html;
// Use DOMPurify to safely remove style attributes // Pre-strip style attributes from the raw string BEFORE DOMPurify parses it.
// This is more secure than regex-based approaches and handles edge cases properly // DOMPurify internally uses innerHTML to parse HTML, which triggers CSP
return DOMPurify.sanitize(html, { // `style-src` violations at parse time — before FORBID_ATTR can strip them.
// The regex is O(n) safe: [^"]* and [^']* are negated classes bounded by
// fixed quote delimiters, so no backtracking can occur.
const preStripped = html.replaceAll(/ style="[^"]*"| style='[^']*'/gi, "");
return DOMPurify.sanitize(preStripped, {
FORBID_ATTR: ["style"], FORBID_ATTR: ["style"],
// Preserve the target attribute (e.g. target="_blank" on links) which is not
// in DOMPurify's default allow-list but is explicitly required downstream.
ADD_ATTR: ["target"], ADD_ATTR: ["target"],
// Keep other attributes and tags as-is, only remove style attributes
KEEP_CONTENT: true, KEEP_CONTENT: true,
}); });
}; };