Skip to content

Commit 33269d9

Browse files
elemdosclaude
andcommitted
fix: prevent infinite loops and dashboard freezes
- Add reentrancy guard to _notify() in data module (iframe + production) - Simplify ProjectThumbnail to only use published HTML (no on-the-fly compile) - Only sync changed fields in realtime updates to avoid false freeze detection - Preserve kit context in header logo link 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent c49aefe commit 33269d9

File tree

6 files changed

+54
-83
lines changed

6 files changed

+54
-83
lines changed

src/lib/compiler/iframe.js

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ function create_collection(name) {
8585
let cache = null
8686
let last_params = null
8787
let mutation_cooldown = 0 // Timestamp until which we ignore external updates
88+
let notifying = false // Reentrancy guard to prevent infinite loops
8889
8990
// Compare two record arrays for equality (by JSON serialization)
9091
function records_equal(a, b) {
@@ -100,6 +101,10 @@ function create_collection(name) {
100101
const collection = {
101102
// Notify all subscribers with new data (skip if unchanged or in cooldown)
102103
_notify(records, force = false) {
104+
// Prevent reentrant calls - if a subscriber triggers another mutation,
105+
// don't recursively notify (the mutation will schedule its own notification)
106+
if (notifying) return
107+
103108
// During cooldown period, ignore external updates (SSE/realtime)
104109
// This prevents re-renders when our own mutations echo back
105110
if (!force && Date.now() < mutation_cooldown) {
@@ -109,8 +114,13 @@ function create_collection(name) {
109114
return // Skip notification if data hasn't changed
110115
}
111116
cache = records
112-
for (const cb of subscribers) {
113-
try { cb(records) } catch (e) { console.error('[db] subscriber error:', e) }
117+
notifying = true
118+
try {
119+
for (const cb of subscribers) {
120+
try { cb(records) } catch (e) { console.error('[db] subscriber error:', e) }
121+
}
122+
} finally {
123+
notifying = false
114124
}
115125
},
116126

src/routes/tinykit/dashboard/components/ProjectThumbnail.svelte

Lines changed: 9 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,17 @@
11
<script lang="ts">
22
import { onMount } from "svelte"
33
import Icon from "@iconify/svelte"
4-
import { processCode, dynamic_iframe_srcdoc } from "$lib/compiler/init"
54
import { pb } from "$lib/pocketbase.svelte"
6-
import type { DesignField, ContentField } from "../../types"
75
86
let {
9-
code = "",
10-
design = [],
11-
content = [],
12-
data = {},
137
compiled_html = "",
148
project_id = "",
159
collection_id = "_tk_projects",
1610
fallback_icon = ""
1711
}: {
18-
code: string
19-
design: DesignField[]
20-
content?: ContentField[]
12+
code?: string
13+
design?: any[]
14+
content?: any[]
2115
data?: Record<string, any>
2216
compiled_html?: string
2317
project_id?: string
@@ -27,36 +21,14 @@
2721
2822
let srcdoc = $state("")
2923
let is_loading = $state(true)
30-
let has_error = $state(false)
31-
let iframe_el = $state<HTMLIFrameElement | null>(null)
32-
let pending_code: string | null = null
33-
34-
function handle_message(e: MessageEvent) {
35-
if (e.source !== iframe_el?.contentWindow) return
36-
const { event: msg_event } = e.data || {}
37-
if (msg_event === "INITIALIZED" && pending_code) {
38-
// Clone data to avoid DataCloneError from Svelte 5 reactive proxies
39-
const cloned_data = JSON.parse(JSON.stringify(data || {}))
40-
// Send component code with data - iframe will populate collections before mounting
41-
iframe_el?.contentWindow?.postMessage({
42-
event: "SET_APP",
43-
payload: { componentApp: pending_code, data: cloned_data }
44-
}, "*")
45-
pending_code = null
46-
}
47-
}
4824
4925
onMount(() => {
50-
window.addEventListener("message", handle_message)
5126
load_preview()
52-
53-
return () => {
54-
window.removeEventListener("message", handle_message)
55-
}
5627
})
5728
5829
async function load_preview() {
5930
// If we have a published_html file, fetch its contents
31+
// This is the ONLY safe way to show thumbnails - published HTML is pre-compiled
6032
if (compiled_html && project_id) {
6133
try {
6234
const file_url = pb.files.getURL(
@@ -74,44 +46,10 @@
7446
}
7547
}
7648
77-
// Otherwise compile on-the-fly with full data support
78-
if (!code) {
79-
is_loading = false
80-
return
81-
}
82-
83-
try {
84-
const result = await processCode({
85-
component: code,
86-
buildStatic: false,
87-
runtime: ["mount", "unmount"]
88-
})
89-
90-
if (result.error || !result.js) {
91-
has_error = true
92-
is_loading = false
93-
return
94-
}
95-
96-
// Extract collection names from data object
97-
const data_collections = Object.keys(data || {})
98-
99-
// Store compiled code to send when iframe is ready
100-
pending_code = result.js
101-
102-
// Build srcdoc with full data module support
103-
srcdoc = dynamic_iframe_srcdoc("", {
104-
content: content || [],
105-
design: design || [],
106-
project_id: project_id || "",
107-
data_collections
108-
})
109-
} catch (err) {
110-
console.error("[ProjectThumbnail] Compile error:", err)
111-
has_error = true
112-
} finally {
113-
is_loading = false
114-
}
49+
// Don't compile on-the-fly for thumbnails - it's too risky
50+
// Complex apps can freeze the entire dashboard with infinite loops
51+
// Just show a placeholder if no published HTML exists
52+
is_loading = false
11553
}
11654
</script>
11755

@@ -120,7 +58,7 @@
12058
<div class="placeholder loading">
12159
<div class="spinner"></div>
12260
</div>
123-
{:else if has_error || !srcdoc}
61+
{:else if !srcdoc}
12462
<div class="placeholder ghost">
12563
{#if fallback_icon}
12664
<Icon icon={fallback_icon} class="fallback-icon" />
@@ -130,7 +68,6 @@
13068
</div>
13169
{:else}
13270
<iframe
133-
bind:this={iframe_el}
13471
title="Project preview"
13572
{srcdoc}
13673
sandbox="allow-scripts allow-same-origin"

src/routes/tinykit/lib/api.svelte.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -601,6 +601,7 @@ function create_collection(name) {
601601
let cache = null
602602
let last_params = null
603603
let mutation_cooldown = 0 // Timestamp until which we ignore external updates
604+
let notifying = false // Reentrancy guard to prevent infinite loops
604605
605606
// Compare two record arrays for equality (by JSON serialization)
606607
function records_equal(a, b) {
@@ -663,6 +664,10 @@ function create_collection(name) {
663664
const collection = {
664665
// Notify all subscribers with new data (skip if unchanged or in cooldown)
665666
_notify(records, force = false) {
667+
// Prevent reentrant calls - if a subscriber triggers another mutation,
668+
// don't recursively notify (the mutation will schedule its own notification)
669+
if (notifying) return
670+
666671
// During cooldown period, ignore external updates (SSE/realtime)
667672
// This prevents re-renders when our own mutations echo back
668673
if (!force && Date.now() < mutation_cooldown) {
@@ -672,8 +677,13 @@ function create_collection(name) {
672677
return // Skip notification if data hasn't changed
673678
}
674679
cache = records
675-
for (const cb of subscribers) {
676-
try { cb(records) } catch (e) { console.error('[db] subscriber error:', e) }
680+
notifying = true
681+
try {
682+
for (const cb of subscribers) {
683+
try { cb(records) } catch (e) { console.error('[db] subscriber error:', e) }
684+
}
685+
} finally {
686+
notifying = false
677687
}
678688
},
679689

src/routes/tinykit/studio/+page.svelte

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1011,6 +1011,7 @@
10111011
<Header
10121012
project_title={project_title ?? ""}
10131013
project_id={store?.project?.id ?? ""}
1014+
kit_id={store?.project?.kit}
10141015
{vibe_zone_enabled}
10151016
bind:preview_position
10161017
{project_domain}

src/routes/tinykit/studio/components/Header.svelte

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
type HeaderProps = {
3030
project_title: string;
3131
project_domain: string;
32+
kit_id?: string;
3233
is_deploying: boolean;
3334
vibe_zone_enabled: boolean;
3435
preview_position: PreviewPosition;
@@ -53,6 +54,7 @@
5354
let {
5455
project_title = "",
5556
project_domain = "",
57+
kit_id,
5658
is_deploying,
5759
vibe_zone_enabled,
5860
preview_position = $bindable(),
@@ -70,6 +72,8 @@
7072
is_mobile = false,
7173
}: HeaderProps = $props();
7274
75+
let dashboard_href = $derived(kit_id ? `/tinykit?kit=${kit_id}` : "/tinykit");
76+
7377
const position_options: {
7478
id: PreviewPosition;
7579
label: string;
@@ -231,7 +235,7 @@
231235
class="h-14 border-t border-[var(--builder-border)] bg-[var(--builder-bg-primary)] flex items-center justify-between px-4 flex-shrink-0 relative z-30"
232236
>
233237
<!-- Left: Logo -->
234-
<a href="/tinykit" class="logo">
238+
<a href={dashboard_href} class="logo">
235239
<Logo />
236240
</a>
237241

@@ -327,7 +331,7 @@
327331
>
328332
<!-- Left: Logo -->
329333
<div class="flex items-center space-x-3 flex-shrink-0">
330-
<a href="/tinykit" class="logo">
334+
<a href={dashboard_href} class="logo">
331335
<Logo />
332336
</a>
333337
<!-- Project name and save status -->

src/routes/tinykit/studio/project.svelte.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -126,14 +126,23 @@ export class ProjectStore {
126126
const last_msg = incoming_msgs[incoming_msgs.length - 1];
127127
const agent_just_finished = last_msg?.role === 'assistant' && last_msg?.status === 'complete';
128128

129-
// Only sync frontend_code when agent finishes to avoid lockups
130-
// During streaming, we skip code sync - CodeMirror can't handle rapid large updates
129+
// Only sync fields that actually changed to avoid triggering
130+
// unnecessary reactive updates (which can cause false freeze detection)
131+
const incoming_content = JSON.stringify(incoming.content || []);
132+
const current_content = JSON.stringify(this.project?.content || []);
133+
const content_changed = incoming_content !== current_content;
134+
135+
const incoming_design = JSON.stringify(incoming.design || []);
136+
const current_design = JSON.stringify(this.project?.design || []);
137+
const design_changed = incoming_design !== current_design;
138+
131139
this.project = {
132140
...this.project!,
133141
agent_chat: incoming.agent_chat,
134-
content: incoming.content,
135-
design: incoming.design,
136-
data: incoming.data,
142+
// Only sync fields that actually changed
143+
...(content_changed ? { content: incoming.content } : {}),
144+
...(design_changed ? { design: incoming.design } : {}),
145+
...(data_changed ? { data: incoming.data } : {}),
137146
// Sync code only when agent finishes (prevents lockup during streaming)
138147
...(agent_just_finished ? { frontend_code: incoming.frontend_code } : {})
139148
};

0 commit comments

Comments
 (0)