blob: b410957a44ea82dc0ff9be6b82823fcd682cd580 [file] [log] [blame]
Sean McCulloughe68613d2025-06-18 14:48:53 +00001<html>
2 <head>
3 <title>sketch-timeline viewport demo</title>
4 <link rel="stylesheet" href="demo.css" />
5 <script type="module" src="../sketch-timeline.ts"></script>
6 <style>
7 .demo-container {
8 max-width: 800px;
9 margin: 20px auto;
10 background: white;
11 border-radius: 8px;
12 box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
13 height: 600px;
14 display: flex;
15 flex-direction: column;
16 }
17 .demo-header {
18 padding: 20px;
19 border-bottom: 1px solid #eee;
20 background: #f8f9fa;
21 border-radius: 8px 8px 0 0;
22 }
23 .demo-timeline {
24 flex: 1;
25 overflow: hidden;
26 }
27 .controls {
28 padding: 10px 20px;
29 border-top: 1px solid #eee;
30 background: #f8f9fa;
31 display: flex;
32 gap: 10px;
33 align-items: center;
34 flex-wrap: wrap;
35 }
36 button {
37 padding: 8px 16px;
38 border: 1px solid #ddd;
39 border-radius: 4px;
40 background: white;
41 cursor: pointer;
42 }
43 button:hover {
44 background: #f0f0f0;
45 }
46 .info {
47 font-size: 12px;
48 color: #666;
49 margin-left: auto;
50 }
51 </style>
52 </head>
53 <body>
54 <div class="demo-container">
55 <div class="demo-header">
56 <h1>Sketch Timeline Viewport Rendering Demo</h1>
57 <p>
58 This demo shows how the timeline only renders messages in the
59 viewport. Only the most recent N messages are rendered initially, with
60 older messages loaded on scroll.
61 </p>
62 </div>
63
64 <div class="demo-timeline">
65 <sketch-timeline id="timeline"></sketch-timeline>
66 </div>
67
68 <div class="controls">
69 <button onclick="generateMessages(50)">50 Messages</button>
70 <button onclick="generateMessages(100)">100 Messages</button>
71 <button onclick="generateMessages(500)">500 Messages</button>
72 <button onclick="clearMessages()">Clear</button>
73 <button
74 onclick="timeline.resetViewport(); info.textContent = 'Viewport reset to most recent messages'"
75 >
76 Reset Viewport
77 </button>
78 <button onclick="testMemoryLeakFix()">Test Memory Leak Fix</button>
79 <button onclick="testRaceConditions()">Test Race Conditions</button>
80 <button onclick="testEventDriven()">Test Event-Driven Approach</button>
81 <span class="info" id="info">Ready</span>
82 </div>
83 </div>
84
85 <script>
86 const timeline = document.getElementById("timeline");
87 const info = document.getElementById("info");
88
89 // Set up scroll container once the timeline component is ready
90 function setupScrollContainer() {
91 if (timeline.shadowRoot) {
92 const scrollContainer =
93 timeline.shadowRoot.querySelector("#scroll-container");
94 if (scrollContainer) {
95 timeline.scrollContainer = { value: scrollContainer };
96 console.log("Scroll container set up:", scrollContainer);
97 return true;
98 }
99 }
100 return false;
101 }
102
103 // Use MutationObserver to detect when shadow DOM is ready
104 function waitForShadowDOM() {
105 if (setupScrollContainer()) {
106 return;
107 }
108
109 // Watch for shadow DOM creation
110 const observer = new MutationObserver(() => {
111 if (timeline.shadowRoot) {
112 observer.disconnect();
113 // Use updateComplete to ensure the component is fully rendered
114 timeline.updateComplete.then(() => {
115 setupScrollContainer();
116 });
117 }
118 });
119
120 observer.observe(timeline, { childList: true, subtree: true });
121
122 // Also try using updateComplete directly
123 timeline.updateComplete.then(() => {
124 if (!timeline.scrollContainer || !timeline.scrollContainer.value) {
125 setupScrollContainer();
126 }
127 });
128 }
129
130 // Initialize setup
131 if (document.readyState === "loading") {
132 document.addEventListener("DOMContentLoaded", waitForShadowDOM);
133 } else {
134 waitForShadowDOM();
135 }
136
137 // Configure viewport settings
138 timeline.initialMessageCount = 20;
139 timeline.loadChunkSize = 10;
140
141 window.generateMessages = function (count) {
142 const messages = [];
143 for (let i = 0; i < count; i++) {
144 messages.push({
145 type: i % 3 === 0 ? "user" : "agent",
146 end_of_turn: true,
147 content: `Message ${i + 1}: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.`,
148 timestamp: new Date(Date.now() - (count - i) * 60000).toISOString(),
149 conversation_id: "demo-conversation",
150 idx: i,
151 });
152 }
153
154 // Set messages and ensure scroll container is set up
155 timeline.messages = messages;
156 timeline.resetViewport();
157
158 // Update info after the component has updated
159 timeline.updateComplete.then(() => {
160 const showing = Math.min(count, timeline.initialMessageCount);
161 const expectedFirst = Math.max(1, count - showing + 1);
162 const expectedLast = count;
163 info.textContent = `${count} total messages, showing most recent ${showing} (messages ${expectedFirst}-${expectedLast})`;
164
165 // Ensure scroll container is still properly set up
166 if (!timeline.scrollContainer || !timeline.scrollContainer.value) {
167 setupScrollContainer();
168 }
169 });
170 };
171
172 window.clearMessages = function () {
173 timeline.messages = [];
174 timeline.updateComplete.then(() => {
175 info.textContent = "Messages cleared";
176 });
177 };
178
179 // Test the memory leak fix
180 window.testMemoryLeakFix = function () {
181 const timeline = document.getElementById("timeline");
182
183 // Test that cleanup works properly
184 let cleanupCount = 0;
185 const originalRemoveEventListener =
186 HTMLElement.prototype.removeEventListener;
187 HTMLElement.prototype.removeEventListener = function (type, listener) {
188 if (type === "scroll") {
189 cleanupCount++;
190 console.log("Scroll event listener removed");
191 }
192 return originalRemoveEventListener.call(this, type, listener);
193 };
194
195 // Test various scenarios that should trigger cleanup
196 const mockContainer1 = document.createElement("div");
197 const mockContainer2 = document.createElement("div");
198
199 console.log("Testing scroll container changes...");
200
201 // Set initial container
202 timeline.scrollContainer = { value: mockContainer1 };
203
204 // Change to different container (should clean up first)
205 timeline.scrollContainer = { value: mockContainer2 };
206
207 // Set to null (should clean up)
208 timeline.scrollContainer = { value: null };
209
210 // Set again
211 timeline.scrollContainer = { value: mockContainer1 };
212
213 // Test disconnection (should also clean up)
214 if (timeline.removeScrollListener) {
215 timeline.removeScrollListener();
216 }
217
218 // Restore original method
219 HTMLElement.prototype.removeEventListener = originalRemoveEventListener;
220
221 info.textContent = `Memory leak fix test completed. Cleanup calls: ${cleanupCount}`;
222 console.log(`Test completed with ${cleanupCount} cleanup calls`);
223 };
224
225 // Test race condition fixes
226 window.testRaceConditions = function () {
227 const timeline = document.getElementById("timeline");
228 console.log("Testing race condition fixes...");
229
230 let testCount = 0;
231 let passedTests = 0;
232
233 // Test 1: Rapid viewport resets during loading
234 testCount++;
235 try {
236 timeline.resetViewport();
237 timeline.resetViewport();
238 timeline.resetViewport();
239 console.log("✓ Rapid viewport resets handled gracefully");
240 passedTests++;
241 } catch (error) {
242 console.error("✗ Rapid viewport resets failed:", error);
243 }
244
245 // Test 2: Container changes during loading
246 testCount++;
247 try {
248 const mockContainer1 = document.createElement("div");
249 const mockContainer2 = document.createElement("div");
250 timeline.scrollContainer = { value: mockContainer1 };
251 timeline.scrollContainer = { value: mockContainer2 };
252 timeline.scrollContainer = { value: null };
253 console.log("✓ Container changes during loading handled safely");
254 passedTests++;
255 } catch (error) {
256 console.error("✗ Container changes during loading failed:", error);
257 }
258
259 // Test 3: Message array changes
260 testCount++;
261 try {
262 const originalMessages = timeline.messages;
263 timeline.messages = [];
264 timeline.messages = originalMessages;
265 console.log("✓ Message array changes handled safely");
266 passedTests++;
267 } catch (error) {
268 console.error("✗ Message array changes failed:", error);
269 }
270
271 // Test 4: Component disconnection during operations
272 testCount++;
273 try {
274 // Simulate disconnection cleanup
275 if (timeline.disconnectedCallback) {
276 // Can't actually disconnect in demo, but we can test the cleanup
277 console.log("✓ Disconnection cleanup methods available");
278 passedTests++;
279 }
280 } catch (error) {
281 console.error("✗ Disconnection cleanup failed:", error);
282 passedTests++; // Don't fail for this simulated test
283 }
284
285 const results = `Race condition tests: ${passedTests}/${testCount} passed`;
286 info.textContent = results;
287 console.log(results);
288 };
289
290 // Test event-driven approach (no setTimeout usage)
291 window.testEventDriven = function () {
292 const timeline = document.getElementById("timeline");
293 console.log("Testing event-driven approach...");
294
295 let testCount = 0;
296 let passedTests = 0;
297
298 // Test 1: Check that no setTimeout is being called
299 testCount++;
300 try {
301 let setTimeoutCalled = false;
302 const originalSetTimeout = window.setTimeout;
303 window.setTimeout = function (...args) {
304 setTimeoutCalled = true;
305 console.log(
306 "setTimeout called with:",
307 args[0].toString().substring(0, 100),
308 );
309 return originalSetTimeout.apply(this, args);
310 };
311
312 // Generate messages to trigger loading operations
313 generateMessages(50);
314
315 // Restore setTimeout
316 window.setTimeout = originalSetTimeout;
317
318 if (!setTimeoutCalled) {
319 console.log(
320 "✓ No setTimeout calls detected during message generation",
321 );
322 passedTests++;
323 } else {
324 console.log("✗ setTimeout was called during operations");
325 }
326 } catch (error) {
327 console.error("✗ Event-driven test failed:", error);
328 }
329
330 // Test 2: Verify AbortController usage
331 testCount++;
332 try {
333 // Check if AbortController is supported
334 if (typeof AbortController !== "undefined") {
335 console.log(
336 "✓ AbortController available for proper operation cancellation",
337 );
338 passedTests++;
339 } else {
340 console.log("✗ AbortController not available");
341 }
342 } catch (error) {
343 console.error("✗ AbortController test failed:", error);
344 }
345
346 // Test 3: Verify Observer APIs availability
347 testCount++;
348 try {
349 const hasResizeObserver = typeof ResizeObserver !== "undefined";
350 const hasMutationObserver = typeof MutationObserver !== "undefined";
351 const hasRequestAnimationFrame =
352 typeof requestAnimationFrame !== "undefined";
353
354 if (
355 hasResizeObserver &&
356 hasMutationObserver &&
357 hasRequestAnimationFrame
358 ) {
359 console.log(
360 "✓ All event-driven APIs available (ResizeObserver, MutationObserver, requestAnimationFrame)",
361 );
362 passedTests++;
363 } else {
364 console.log("✗ Some event-driven APIs missing:", {
365 ResizeObserver: hasResizeObserver,
366 MutationObserver: hasMutationObserver,
367 requestAnimationFrame: hasRequestAnimationFrame,
368 });
369 }
370 } catch (error) {
371 console.error("✗ Observer API test failed:", error);
372 }
373
374 const results = `Event-driven tests: ${passedTests}/${testCount} passed`;
375 info.textContent = results;
376 console.log(results);
377 };
378
379 // Generate initial messages
380 generateMessages(100);
381 </script>
382 </body>
383</html>