Skip to content

Commit a19b30c

Browse files
committed
bump
1 parent a5f2654 commit a19b30c

File tree

6 files changed

+329
-46
lines changed

6 files changed

+329
-46
lines changed

demo/src/app.svelte

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,62 @@
11
<script lang="ts">
2-
import { ComponentCompiler, type CompiledComponent } from "@mateothegreat/dynamic-component-engine";
2+
import { ComponentCompiler, sharedStore, type CompiledComponent } from "@mateothegreat/dynamic-component-engine";
33
import { onMount } from "svelte";
44
55
let renderRef: HTMLDivElement | undefined = $state(undefined);
66
77
// This is to test that the component is updated when the count changes.
88
let count = $state(0);
9+
10+
// Initialize shared store with initial count value.
11+
sharedStore.set("count", count);
12+
913
setInterval(() => {
1014
count++;
15+
sharedStore.set("count", count); // Update via the shared store
1116
}, 1000);
1217
18+
// Create a reactive reference that stays in sync with the store
19+
$effect(() => {
20+
sharedStore.set("count", count);
21+
});
22+
1323
let instances: CompiledComponent[] = $state([]);
1424
1525
const doWork = async () => {
1626
const source = `
1727
<script>
1828
import { onDestroy } from "svelte";
29+
30+
let { data } = $props();
31+
32+
// Access shared reactive state directly from the store.
33+
let count = $state(sharedStore.get('count') || 0);
1934
20-
let { count = $bindable(0), data } = $props();
35+
// Subscribe to store changes and update local state.
36+
const unsubscribe = sharedStore.subscribe('count', (newCount) => {
37+
count = newCount;
38+
});
2139
2240
$effect(() => {
2341
console.log("count changed to:", count);
2442
});
2543
44+
const incrementCount = () => {
45+
const currentCount = sharedStore.get('count') || 0;
46+
sharedStore.set('count', currentCount + 1);
47+
};
48+
2649
onDestroy(() => {
2750
console.log("I've been destroyed");
51+
unsubscribe();
2852
});
2953
<\/script>
3054
3155
<div class="m-4 p-4 border-2 border-pink-500 rounded">
3256
<p>data: <span class="text-pink-500">{data}</span></p>
3357
<p>count: <span class="text-pink-500">{count}</span></p>
3458
35-
<button onclick={() => {
36-
count++;
37-
}} class="mt-4 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600">
59+
<button onclick={incrementCount} class="mt-4 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600">
3860
Increment
3961
<\/button>
4062
<\/div>`;
@@ -43,13 +65,13 @@
4365
source,
4466
renderRef!,
4567
{
46-
count,
4768
data: "passed at compile time"
4869
},
4970
{
5071
css: true,
5172
sourcemap: true,
52-
cache: false
73+
cache: false,
74+
sharedStore: sharedStore
5375
}
5476
);
5577

demo/vercel.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"routes": [{ "src": "/[^.]+", "dest": "/", "status": 200 }],
3+
"github": {
4+
"enabled": true
5+
}
6+
}

docs/reactivity.md

Lines changed: 121 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,134 @@
1-
Solutions for Rune-Based Reactive State Sharing
1+
# ([Solutions for Rune-Based Reactive State Sharing
22

3-
Solution 1: Reactive Context Injection
3+
The fundamental problem is that Svelte 5 runes create reactive references that lose their reactivity when serialized across compilation boundaries.
44

5-
Modify the factory function to accept reactive context objects that get injected into the
6-
component's compilation scope. Instead of passing static values, pass rune references directly
7-
through the compilation process.
5+
## The Core Issue
86

9-
Approach:
10-
- Extend ComponentCompiler.render() to accept a reactiveContext parameter containing rune
11-
objects
12-
- Modify transformCompiledCode() to inject these runes as imports/globals in the compiled
13-
component
14-
- The dynamic component accesses parent runes directly via injected context
7+
When we have `let count = $state(123)` in our parent component, we're creating a reactive reference.
158

16-
Solution 2: Rune Reference Passing
9+
However, when we try to pass this to a dynamically compiled component, several things break the reactivity chain:
10+
11+
### 1. **Value** vs **Reference** Passing
12+
13+
Parent component:
14+
15+
```ts
16+
let count = $state(123);
17+
```
18+
19+
What you're actually passing to the dynamic component:
20+
21+
```ts
22+
const props = { count }; // This passes the VALUE (123), not the reactive reference
23+
```
24+
25+
The `$state(123)` creates a reactive proxy/reference, but when you put count in the props object, you're passing the current value (`123`), not the reactive reference itself.
26+
27+
### 2. Compilation Boundary Isolation
28+
29+
Parent component (compiled at build time):
30+
31+
```ts
32+
let count = $state(123);
33+
```
34+
35+
Dynamic component (compiled at runtime):
36+
37+
```ts
38+
let { count } = $props(); // This creates a NEW local variable, disconnected from parent
39+
```
40+
41+
Each Svelte compilation creates its own reactive scope. The dynamically compiled component has no knowledge of the parent's reactive system - **it's essentially a _separate_ "universe" of reactivity**.
42+
43+
### 3. Svelte's Internal Reactivity System
44+
45+
Svelte 5 runes work through:
46+
47+
+ Reactive proxies that track dependencies.
48+
+ Effect scheduling that runs when dependencies change.
49+
+ Component boundaries that isolate reactive scopes
50+
51+
When you pass count as a prop, the dynamic component receives a **snapshot** _value_, **not** a reactive subscription.
52+
53+
### 4. The Mount/Unmount API Limitation
54+
55+
```ts
56+
const component = mount(DynamicComponent, { target, props: { count: 123 } });
57+
```
58+
59+
Svelte's `mount()` API expects static props at mount time. It doesn't have a built-in mechanism to pass ongoing reactive references that update the component after mounting.
60+
61+
### Why The Original Approach Didn't Work
62+
63+
Parent component:
64+
65+
```ts
66+
let count = $state(123);
67+
```
68+
69+
Dynamic component template:
70+
71+
```ts
72+
let { count = $bindable(0) } = $props();
73+
```
74+
75+
This creates two separate reactive variables with the same name but no connection between them.
76+
77+
The `$bindable()` in the dynamic component creates its own reactive reference, completely isolated from the parent's `$state()`.
78+
79+
### The Real Solution Would Require
80+
81+
1) Passing reactive references (not values) across compilation boundaries.
82+
2) Shared reactive context that both components can access.
83+
3) Live prop updates after component mounting.
84+
4) Cross-compilation reactive dependency tracking.
85+
86+
This is why the `SharedRuneStore` solution works (see [/src/shared-rune-store.ts](../src/shared-rune-store.ts)) - it creates a shared reactive context that both the parent and dynamic components can subscribe to, bypassing the compilation boundary limitations.
87+
88+
## Potential Solutions
89+
90+
### Solution 1: Reactive Context Injection
91+
92+
Modify the factory function to accept reactive context objects that get injected into the component's compilation scope.
93+
94+
Instead of passing static values, pass rune references directly through the compilation process.
95+
96+
**Approach**:
97+
98+
+ Extend `ComponentCompiler.render()` to accept a reactiveContext parameter containing rune objects.
99+
+ Modify transformCompiledCode() to inject these runes as imports/globals in the compiled component.
100+
+ The dynamic component accesses parent runes directly via injected context.
101+
102+
### Solution 2: Rune Reference Passing
17103

18104
Create a mechanism to pass actual rune references (not their values) through the mount system by
19-
serializing rune getters/setters and reconstructing them in the dynamic component.
20105

21-
Approach:
22-
- Extend the factory function to accept rune descriptors ({ get: () => value, set: (v) => {...}
23-
})
24-
- Transform the compiled code to wire these descriptors to local rune state
25-
- Dynamic components get direct access to parent runes through the descriptor interface
106+
serializing rune getters/setters and reconstructing them in the dynamic component.
107+
108+
**Approach**:
109+
110+
+ Extend the factory function to accept rune descriptors `({ get: () => value, set: (v) => {...} })`.
111+
+ Transform the compiled code to wire these descriptors to local rune state.
112+
+ Dynamic components get direct access to parent runes through the descriptor interface.
113+
114+
### Solution 3: Shared Rune Store
115+
116+
Implement a global rune store that both parent and dynamic components can subscribe to, using Svelte 5's $state() with a singleton pattern.
26117

27-
Solution 3: Shared Rune Store
118+
**Approach**:
28119

29-
Implement a global rune store that both parent and dynamic components can subscribe to, using
30-
Svelte 5's $state() with a singleton pattern.
120+
+ Create a `SharedRuneStore` class with `$state()` values.
121+
+ Parent components register their runes in the store.
122+
+ Dynamic components access the same rune references from the store.
123+
+ No wrapper/proxy - direct rune access through shared state container.
31124

32-
Approach:
33-
- Create a SharedRuneStore class with $state() values
34-
- Parent components register their runes in the store
35-
- Dynamic components access the same rune references from the store
36-
- No wrapper/proxy - direct rune access through shared state container
125+
### Solution 4: Compilation-Time Rune Binding
37126

38-
Solution 4: Compilation-Time Rune Binding
127+
Modify the source transformation to replace rune references in the dynamic component with bindings to externally provided rune objects.
39128

40-
Modify the source transformation to replace rune references in the dynamic component with
41-
bindings to externally provided rune objects.
129+
**Approach**:
42130

43-
Approach:
44-
- Parse the dynamic component source for rune usage patterns
45-
- Replace let { count = $bindable(0) } = $props() with direct external rune bindings
46-
- Inject the actual parent runes as module-level imports during compilation
47-
- Component uses parent runes as if they were its own
131+
+ Parse the dynamic component source for rune usage patterns.
132+
+ Replace `let { count = $bindable(0) } = $props()` with direct external rune bindings.
133+
+ Inject the actual parent runes as module-level imports during compilation.
134+
+ Component uses parent runes as if they were its own.

src/compiler/runtime.ts

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
import { compile, type CompileOptions, type CompileResult } from "svelte/compiler";
2+
import { SharedRuneStore } from "../shared-rune-store.js";
3+
4+
// Re-export for convenience
5+
export { SharedRuneStore, sharedStore } from "../shared-rune-store.js";
26

37
export interface CompiledComponent {
48
component: any;
@@ -21,6 +25,7 @@ export interface CompilerOptions {
2125
onDestroy?: () => void;
2226
onError?: (error: Error) => void;
2327
useSandbox?: boolean; // Optional: render inside iframe for isolation
28+
sharedStore?: SharedRuneStore; // Optional: shared rune store for reactive state
2429
}
2530

2631
/**
@@ -85,17 +90,22 @@ export class ComponentCompiler {
8590
*
8691
* @param code - Compiled JS code.
8792
* @param componentName - Name of the component.
93+
* @param sharedStore - Optional shared rune store for reactive state.
8894
*
8995
* @returns Transformed JS code as string.
9096
*/
91-
private static transformCompiledCode(code: string, componentName: string): string {
97+
private static transformCompiledCode(code: string, componentName: string, sharedStore?: SharedRuneStore): string {
9298
const lines = code.split("\n");
9399

94100
/**
95101
* Inject mount and unmount imports so that the wrapper can do it's job.
96102
*/
97103
lines.unshift(`import { mount, unmount } from "svelte";`);
98104

105+
/**
106+
* If a shared store is provided, inject it as a parameter in the factory function.
107+
*/
108+
99109
/**
100110
* Replace default export with named component so that when the component is rendered,
101111
* the component is not exported as default but as a named component.
@@ -114,7 +124,20 @@ export class ComponentCompiler {
114124
* 2. Mount the component into the target element.
115125
* 3. Return the component and the destroy function.
116126
*/
117-
lines.push(`
127+
const factoryCode = sharedStore
128+
? `
129+
const factory = (target, props, sharedStore) => {
130+
// Make sharedStore globally available within this module
131+
globalThis.sharedStore = sharedStore;
132+
const component = mount(${componentName}, { target, props });
133+
return {
134+
component,
135+
destroy: () => unmount(component)
136+
};
137+
};
138+
export { factory as default };
139+
`
140+
: `
118141
const factory = (target, props) => {
119142
const component = mount(${componentName}, { target, props });
120143
return {
@@ -123,7 +146,9 @@ export class ComponentCompiler {
123146
};
124147
};
125148
export { factory as default };
126-
`);
149+
`;
150+
151+
lines.push(factoryCode);
127152

128153
return lines.join("\n");
129154
}
@@ -163,12 +188,12 @@ export class ComponentCompiler {
163188
* ```
164189
*/
165190
const componentName = options.name?.replace(/\.(svelte|js)$/, "") || "DynamicComponent";
166-
const transformedCode = this.transformCompiledCode(compiled.js.code, componentName);
191+
const transformedCode = this.transformCompiledCode(compiled.js.code, componentName, options.sharedStore);
167192

168193
fn = await this.loadModule(transformedCode);
169194
this.componentCache.set(cacheKey, fn);
170195

171-
const instance = fn(target, props);
196+
const instance = options.sharedStore ? fn(target, props, options.sharedStore) : fn(target, props);
172197

173198
const component = {
174199
component: instance.component,

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@
22
export * from "./compiler";
33
export * from "./dynamic-components";
44
export * from "./loader";
5+
export * from "./shared-rune-store";

0 commit comments

Comments
 (0)