Skip to content
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
2 changes: 1 addition & 1 deletion @types/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ export {};
declare global {
interface Window {
TS: {
token: string;
token?: string;
url?: string;
cookieName?: string;
getUserId?: () => string;
Expand Down
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,13 @@ window.TS = {
import "@topsort/analytics.js";
```

The library will automatically start listening for DOM changes and user interactions once it's imported.
The library will automatically start listening for DOM changes and user interactions once it's imported. If the token is not provided at initialization, events are queued until the token is set. To flush the queue, assign the token via **property assignment**:

```js
window.TS.token = "<YOUR-TOPSORT.JS-TOKEN>";
```

> **Important:** Do **not** replace the entire `window.TS` object after the library has loaded (e.g. `window.TS = { token: "..." }`). The library installs an internal setter on the existing `window.TS` reference to detect when the token becomes available. Replacing the object destroys that setter and the queued events will never be sent.

### Option 2: With a Local Script File

Expand Down
56 changes: 45 additions & 11 deletions demo/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,23 @@
background: #ffebee;
}

.event-entry .api-status.queued {
color: #7c4a00;
background: #fff3cd;
}

.no-token-notice {
display: none;
padding: 10px 16px;
background: #fff8e1;
border-bottom: 1px solid #ffe082;
font-size: 12px;
color: #7c4a00;
line-height: 1.5;
}

.no-token-notice.visible { display: block; }

/* Snippet */
#snippet-area {
padding: 16px;
Expand Down Expand Up @@ -448,10 +465,11 @@ <h1>analytics.js</h1>
<legend>Configuration</legend>
<div class="field">
<label for="token">API Token</label>
<input type="password" id="token" name="token" placeholder="TSE_..." required />
<input type="password" id="token" name="token" placeholder="TSE_..." />
<p class="hint">Leave empty to start without a token — events will queue and flush once a token is set.</p>
</div>
<div class="field">
<label for="api-url">API URL (optional)</label>
<label for="api-url">API URL<span style="font-weight:400;color:#999">(optional)</span></label>
<input type="text" id="api-url" name="api-url" placeholder="https://api.topsort.com" />
<p class="hint">Leave empty for production. Use a staging URL for testing.</p>
</div>
Expand Down Expand Up @@ -533,6 +551,9 @@ <h2>Event Log</h2>
Captured <code>topsort</code> CustomEvents
<span class="badge" id="event-count">0</span>
</div>
<div id="no-token-notice" class="no-token-notice">
No API token set — events are queued locally and won't be sent to the API. Enter a token in the configuration panel to flush the queue.
</div>
<div id="event-log">
<span class="empty-state">Events will appear here as they are detected by analytics.js.</span>
</div>
Expand All @@ -555,6 +576,7 @@ <h2>Code Snippet</h2>
const form = document.getElementById("config-form");
const previewArea = document.getElementById("preview-area");
const eventLog = document.getElementById("event-log");
const noTokenNotice = document.getElementById("no-token-notice");
const snippetCode = document.getElementById("snippet-code");
const elementCountBadge = document.getElementById("element-count");
const eventCountBadge = document.getElementById("event-count");
Expand Down Expand Up @@ -582,7 +604,7 @@ <h2>Code Snippet</h2>
if (!entry) return;
const badge = entry.querySelector(".api-status");
if (!badge) return;
badge.classList.remove("pending");
badge.classList.remove("pending", "queued");
if (ok) {
badge.classList.add("ok");
badge.textContent = `Success (${status})`;
Expand Down Expand Up @@ -687,11 +709,12 @@ <h2>Code Snippet</h2>
detailText = parts.join(", ") || "no product/bid";
}

const hasToken = !!(window.TS && window.TS.token);
entry.innerHTML = `
<span class="event-time">${escapeHtml(time)}</span>
<span class="event-type ${typeLower}">${escapeHtml(detail.type)}</span>
<span class="event-detail">${escapeHtml(detailText)}</span>
<span class="api-status pending">Sending…</span>
<span class="api-status ${hasToken ? "pending" : "queued"}">${hasToken ? "Sending…" : "Queued (no token)"}</span>
`;

eventLog.prepend(entry);
Expand All @@ -700,19 +723,17 @@ <h2>Code Snippet</h2>
}

function ensureAnalyticsLoaded() {
if (analyticsLoaded) return;
if (analyticsLoaded) return true;

const token = document.getElementById("token").value.trim();
if (!token) {
alert("API Token is required to initialize analytics.js.");
return false;
}

const url = document.getElementById("api-url").value.trim();

window.TS = { token };
window.TS = {};
if (token) window.TS.token = token;
if (url) window.TS.url = url;

if (!token) noTokenNotice.classList.add("visible");

patchFetchForEventApiStatus();

// Load the analytics.js script
Expand Down Expand Up @@ -875,11 +896,24 @@ <h2>Code Snippet</h2>
eventCountBadge.textContent = "0";
snippetCode.textContent = "<!-- Add elements to see the code snippet -->";

noTokenNotice.classList.remove("visible");

// Reset analytics.js so it can re-observe new elements
if (window.TS) window.TS.loaded = false;
analyticsLoaded = false;
});

// When the token field is updated after the library is already loaded,
// set it directly on window.TS so the watchForToken setter fires and drains the queue.
document.getElementById("token").addEventListener("change", () => {
if (!analyticsLoaded) return;
const token = document.getElementById("token").value.trim();
if (token) {
window.TS.token = token;
noTokenNotice.classList.remove("visible");
}
});

// Initialize snippet
updateSnippet();
</script>
Expand Down
14 changes: 10 additions & 4 deletions src/detector.bootstrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,23 +32,29 @@ describe("detector bootstrap", () => {
expect(observe).not.toHaveBeenCalled();
});

test("logs when token is missing", async () => {
test("starts observing and installs token watcher when token is missing", async () => {
const observe = vi.fn();
class MutationObserverMock {
observe = observe;
disconnect = vi.fn();
takeRecords = vi.fn(() => []);
}
Object.defineProperty(window, "MutationObserver", {
configurable: true,
writable: true,
value: MutationObserverMock,
});

const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
window.TS = {} as typeof window.TS;

await import("./detector");
expect(error).toHaveBeenCalledWith("Missing TS token");
expect(observe).not.toHaveBeenCalled();

// DOM observation starts even without a token
expect(observe).toHaveBeenCalled();
// A getter/setter is installed on window.TS.token so setting it later drains the queue
const descriptor = Object.getOwnPropertyDescriptor(window.TS, "token");
expect(typeof descriptor?.get).toBe("function");
expect(typeof descriptor?.set).toBe("function");
});

test("registers DOMContentLoaded listener when document is loading", async () => {
Expand Down
98 changes: 98 additions & 0 deletions src/detector.deferred-token.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { afterEach, beforeEach, expect, test, vi } from "vitest";

beforeEach(() => {
vi.resetModules();
vi.restoreAllMocks();
document.body.innerHTML = "";
document.cookie = "";
});

afterEach(() => {
vi.restoreAllMocks();
});

test("captures impressions before token is set", async () => {
window.TS = {} as typeof window.TS;
document.body.innerHTML = '<div data-ts-product="prod-1"></div>';

const events: CustomEvent[] = [];
window.addEventListener("topsort", (e) => events.push(e as CustomEvent));

await import("./detector");

expect(events).toHaveLength(1);
expect(events[0]?.detail.type).toBe("Impression");
expect(events[0]?.detail.product).toBe("prod-1");
});

test("captures clicks before token is set", async () => {
window.TS = {} as typeof window.TS;
document.body.innerHTML = '<div data-ts-product="prod-click"></div>';

const events: CustomEvent[] = [];
window.addEventListener("topsort", (e) => events.push(e as CustomEvent));

await import("./detector");

const card = document.querySelector<HTMLElement>("[data-ts-product]");
card?.dispatchEvent(new MouseEvent("click", { bubbles: true }));

const clickEvent = events.find((e) => e.detail.type === "Click");
expect(clickEvent).toBeDefined();
expect(clickEvent?.detail.product).toBe("prod-click");
});

test("observes dynamically added elements before token is set", async () => {
window.TS = {} as typeof window.TS;

const events: CustomEvent[] = [];
window.addEventListener("topsort", (e) => events.push(e as CustomEvent));

await import("./detector");

const div = document.createElement("div");
div.dataset.tsProduct = "prod-dynamic";
document.body.appendChild(div);
await new Promise(process.nextTick);

expect(events.some((e) => e.detail.product === "prod-dynamic")).toBe(true);
});

test("installs getter/setter on window.TS.token when no token is provided", async () => {
window.TS = {} as typeof window.TS;
await import("./detector");

const descriptor = Object.getOwnPropertyDescriptor(window.TS, "token");
expect(typeof descriptor?.get).toBe("function");
expect(typeof descriptor?.set).toBe("function");
expect(descriptor?.configurable).toBe(true);
});

test("does not install token watcher when token is already present", async () => {
window.TS = { token: "existing-token" };
await import("./detector");

// Plain data property — no getter/setter installed
const descriptor = Object.getOwnPropertyDescriptor(window.TS, "token");
expect(descriptor?.get).toBeUndefined();
expect(descriptor?.value).toBe("existing-token");
});

test("setting the token via watcher makes it readable through the getter", async () => {
window.TS = {} as typeof window.TS;
await import("./detector");

window.TS.token = "deferred-token";

expect(window.TS.token).toBe("deferred-token");
});

test("setting the token twice updates the value", async () => {
window.TS = {} as typeof window.TS;
await import("./detector");

window.TS.token = "token-v1";
window.TS.token = "token-v2";

expect(window.TS.token).toBe("token-v2");
});
36 changes: 32 additions & 4 deletions src/detector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,9 @@ function getApiPayload(event: ProductEvent): TopsortEvent {

// TODO: batch requests. Unfortunately at the moment only the impressions are batchable.
async function processor(data: ProductEvent[]): Promise<ProcessorResult> {
if (!window.TS.token) {
return { done: new Set(), retry: new Set(), pause: true };
}
const r: ProcessorResult = {
done: new Set(),
retry: new Set(),
Expand Down Expand Up @@ -337,15 +340,37 @@ function mutationCallback(mutationsList: MutationRecord[]) {
}
}

function watchForToken(): void {
// NOTE: This setter is installed on the *current* window.TS object reference.
// If the user replaces the entire object (window.TS = { token: "abc" }) the setter
// is lost and the queue will never be drained. Users must assign to the property
// directly: window.TS.token = "abc".
const ts = window.TS;
let token: string | undefined;
Object.defineProperty(ts, "token", {
get(): string | undefined {
return token;
},
set(value: string) {
token = value;
if (value) {
queue.drain();
}
},
configurable: true,
});
console.warn(
"[Topsort] No API token found. Events will be queued until window.TS.token is set. " +
"Use property assignment (window.TS.token = '<token>'), not object replacement " +
"(window.TS = { token: '<token>' }), or the queue will not be drained.",
);
}

function start() {
if (window.TS?.loaded) {
return;
}
window.TS.loaded = true;
if (!window.TS?.token) {
console.error("Missing TS token");
return;
}
checkChildren(document);
const MutationObserverImpl =
window.MutationObserver || window.WebKitMutationObserver || window.MozMutationObserver;
Expand All @@ -356,6 +381,9 @@ function start() {
subtree: true,
attributeFilter: ["data-ts-product", "data-ts-action", "data-ts-items", "data-ts-resolved-bid"],
});
if (!window.TS.token) {
watchForToken();
}
}

if (/complete|interactive|loaded/.test(document.readyState)) {
Expand Down
Loading
Loading