@useavalon/vue 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -42
- package/client/hmr-adapter.ts +226 -0
- package/client/hydration.ts +124 -124
- package/client/index.ts +7 -7
- package/mod.ts +59 -59
- package/package.json +23 -7
- package/server/css-extractor.ts +175 -175
- package/server/renderer.ts +116 -116
- package/types.ts +80 -80
- package/vitest.config.ts +0 -13
package/README.md
CHANGED
|
@@ -1,42 +1,42 @@
|
|
|
1
|
-
# @useavalon/vue
|
|
2
|
-
|
|
3
|
-
Vue 3 integration for [Avalon](https://useavalon.dev). Server-side rendering and client-side hydration for Vue Single File Components as islands.
|
|
4
|
-
|
|
5
|
-
## Features
|
|
6
|
-
|
|
7
|
-
- Vue 3 with Composition API and `<script setup>`
|
|
8
|
-
- Server-side rendering via `@vue/server-renderer`
|
|
9
|
-
- Scoped CSS extraction from SFCs
|
|
10
|
-
- All hydration strategies (`on:client`, `on:visible`, `on:idle`, `on:interaction`)
|
|
11
|
-
|
|
12
|
-
## Usage
|
|
13
|
-
|
|
14
|
-
```vue
|
|
15
|
-
<!-- components/Counter.vue -->
|
|
16
|
-
<script setup>
|
|
17
|
-
import { ref } from 'vue';
|
|
18
|
-
const count = ref(0);
|
|
19
|
-
</script>
|
|
20
|
-
|
|
21
|
-
<template>
|
|
22
|
-
<button @click="count++">Count: {{ count }}</button>
|
|
23
|
-
</template>
|
|
24
|
-
```
|
|
25
|
-
|
|
26
|
-
```tsx
|
|
27
|
-
// pages/index.tsx
|
|
28
|
-
import Counter from '../components/Counter.vue';
|
|
29
|
-
|
|
30
|
-
export default function Home() {
|
|
31
|
-
return <Counter island={{ condition: 'on:visible' }} />;
|
|
32
|
-
}
|
|
33
|
-
```
|
|
34
|
-
|
|
35
|
-
## Links
|
|
36
|
-
|
|
37
|
-
- [Documentation](https://useavalon.dev/docs/frameworks/vue)
|
|
38
|
-
- [GitHub](https://github.com/useAvalon/Avalon)
|
|
39
|
-
|
|
40
|
-
## License
|
|
41
|
-
|
|
42
|
-
MIT
|
|
1
|
+
# @useavalon/vue
|
|
2
|
+
|
|
3
|
+
Vue 3 integration for [Avalon](https://useavalon.dev). Server-side rendering and client-side hydration for Vue Single File Components as islands.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- Vue 3 with Composition API and `<script setup>`
|
|
8
|
+
- Server-side rendering via `@vue/server-renderer`
|
|
9
|
+
- Scoped CSS extraction from SFCs
|
|
10
|
+
- All hydration strategies (`on:client`, `on:visible`, `on:idle`, `on:interaction`)
|
|
11
|
+
|
|
12
|
+
## Usage
|
|
13
|
+
|
|
14
|
+
```vue
|
|
15
|
+
<!-- components/Counter.vue -->
|
|
16
|
+
<script setup>
|
|
17
|
+
import { ref } from 'vue';
|
|
18
|
+
const count = ref(0);
|
|
19
|
+
</script>
|
|
20
|
+
|
|
21
|
+
<template>
|
|
22
|
+
<button @click="count++">Count: {{ count }}</button>
|
|
23
|
+
</template>
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
```tsx
|
|
27
|
+
// pages/index.tsx
|
|
28
|
+
import Counter from '../components/Counter.vue';
|
|
29
|
+
|
|
30
|
+
export default function Home() {
|
|
31
|
+
return <Counter island={{ condition: 'on:visible' }} />;
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Links
|
|
36
|
+
|
|
37
|
+
- [Documentation](https://useavalon.dev/docs/frameworks/vue)
|
|
38
|
+
- [GitHub](https://github.com/useAvalon/Avalon)
|
|
39
|
+
|
|
40
|
+
## License
|
|
41
|
+
|
|
42
|
+
MIT
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vue HMR Adapter
|
|
3
|
+
*
|
|
4
|
+
* Provides Hot Module Replacement support for Vue 3 components.
|
|
5
|
+
* Integrates with @vitejs/plugin-vue to preserve reactive state during updates.
|
|
6
|
+
* Uses Vue's __VUE_HMR_RUNTIME__ API for hot updates.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/// <reference lib="dom" />
|
|
10
|
+
|
|
11
|
+
import { BaseFrameworkAdapter, type StateSnapshot } from '@useavalon/avalon/client/hmr';
|
|
12
|
+
|
|
13
|
+
type VueComponent<P = Record<string, unknown>> = VueComponentOptions<P> | ((props: P) => unknown);
|
|
14
|
+
|
|
15
|
+
interface VueComponentOptions<P = Record<string, unknown>> {
|
|
16
|
+
name?: string;
|
|
17
|
+
props?: string[] | Record<string, unknown>;
|
|
18
|
+
data?: () => Record<string, unknown>;
|
|
19
|
+
setup?: (props: P, context: unknown) => unknown;
|
|
20
|
+
render?: () => unknown;
|
|
21
|
+
template?: string;
|
|
22
|
+
components?: Record<string, VueComponent>;
|
|
23
|
+
computed?: Record<string, () => unknown>;
|
|
24
|
+
methods?: Record<string, (...args: unknown[]) => unknown>;
|
|
25
|
+
watch?: Record<string, unknown>;
|
|
26
|
+
emits?: string[] | Record<string, unknown>;
|
|
27
|
+
expose?: string[];
|
|
28
|
+
beforeCreate?: () => void;
|
|
29
|
+
created?: () => void;
|
|
30
|
+
beforeMount?: () => void;
|
|
31
|
+
mounted?: () => void;
|
|
32
|
+
beforeUpdate?: () => void;
|
|
33
|
+
updated?: () => void;
|
|
34
|
+
beforeUnmount?: () => void;
|
|
35
|
+
unmounted?: () => void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface VueApp {
|
|
39
|
+
mount(rootContainer: HTMLElement | string, isHydrate?: boolean): unknown;
|
|
40
|
+
unmount(): void;
|
|
41
|
+
use(plugin: unknown, ...options: unknown[]): this;
|
|
42
|
+
component(name: string, component: VueComponent): this;
|
|
43
|
+
directive(name: string, directive: unknown): this;
|
|
44
|
+
provide(key: string | symbol, value: unknown): this;
|
|
45
|
+
config: {
|
|
46
|
+
errorHandler?: (err: Error, instance: unknown, info: string) => void;
|
|
47
|
+
warnHandler?: (msg: string, instance: unknown, trace: string) => void;
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface VueModule {
|
|
52
|
+
createApp(rootComponent: VueComponent, rootProps?: Record<string, unknown>): VueApp;
|
|
53
|
+
version: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface VueHMRRuntime {
|
|
57
|
+
createRecord(id: string, component: VueComponent): boolean;
|
|
58
|
+
reload(id: string, component: VueComponent): void;
|
|
59
|
+
rerender(id: string, render: () => unknown): void;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
declare global {
|
|
63
|
+
var __VUE_HMR_RUNTIME__: VueHMRRuntime | undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface VueStateSnapshot extends StateSnapshot {
|
|
67
|
+
framework: 'vue';
|
|
68
|
+
data: {
|
|
69
|
+
reactiveData?: Record<string, unknown>;
|
|
70
|
+
componentName?: string;
|
|
71
|
+
capturedProps?: Record<string, unknown>;
|
|
72
|
+
computedValues?: Record<string, unknown>;
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export class VueHMRAdapter extends BaseFrameworkAdapter {
|
|
77
|
+
readonly name = 'vue';
|
|
78
|
+
private apps: WeakMap<HTMLElement, VueApp> = new WeakMap();
|
|
79
|
+
private componentIds: WeakMap<HTMLElement, string> = new WeakMap();
|
|
80
|
+
|
|
81
|
+
canHandle(component: unknown): boolean {
|
|
82
|
+
if (!component) return false;
|
|
83
|
+
if (typeof component === 'function') return true;
|
|
84
|
+
if (typeof component !== 'object') return false;
|
|
85
|
+
const obj = component as Record<string, unknown>;
|
|
86
|
+
if (
|
|
87
|
+
'setup' in obj ||
|
|
88
|
+
'data' in obj ||
|
|
89
|
+
'render' in obj ||
|
|
90
|
+
'template' in obj ||
|
|
91
|
+
'props' in obj ||
|
|
92
|
+
'computed' in obj ||
|
|
93
|
+
'methods' in obj ||
|
|
94
|
+
'components' in obj ||
|
|
95
|
+
'emits' in obj ||
|
|
96
|
+
'mounted' in obj ||
|
|
97
|
+
'created' in obj ||
|
|
98
|
+
'beforeMount' in obj ||
|
|
99
|
+
'beforeCreate' in obj
|
|
100
|
+
)
|
|
101
|
+
return true;
|
|
102
|
+
if ('__vccOpts' in obj) return true;
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
override preserveState(island: HTMLElement): VueStateSnapshot | null {
|
|
107
|
+
try {
|
|
108
|
+
const baseSnapshot = super.preserveState(island);
|
|
109
|
+
if (!baseSnapshot) return null;
|
|
110
|
+
const capturedProps = island.dataset.props ? JSON.parse(island.dataset.props) : {};
|
|
111
|
+
const componentName = this.extractComponentName(island.dataset.src || '');
|
|
112
|
+
const reactiveData = this.captureReactiveData(island);
|
|
113
|
+
return { ...baseSnapshot, framework: 'vue', data: { componentName, capturedProps, reactiveData } };
|
|
114
|
+
} catch (error) {
|
|
115
|
+
console.warn('Failed to preserve Vue state:', error);
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async update(island: HTMLElement, newComponent: unknown, props: Record<string, unknown>): Promise<void> {
|
|
121
|
+
if (!this.canHandle(newComponent)) throw new Error('Component is not a valid Vue component');
|
|
122
|
+
const Component = newComponent as VueComponent;
|
|
123
|
+
try {
|
|
124
|
+
const vueModule = (await import('vue')) as VueModule;
|
|
125
|
+
const { createApp } = vueModule;
|
|
126
|
+
const existingApp = this.apps.get(island);
|
|
127
|
+
const componentId = this.componentIds.get(island);
|
|
128
|
+
const hmrRuntime = globalThis.__VUE_HMR_RUNTIME__;
|
|
129
|
+
if (hmrRuntime && componentId) {
|
|
130
|
+
try {
|
|
131
|
+
hmrRuntime.reload(componentId, Component);
|
|
132
|
+
if (existingApp) return;
|
|
133
|
+
} catch (error) {
|
|
134
|
+
console.warn('Vue HMR runtime reload failed, falling back to full remount:', error);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (existingApp) {
|
|
138
|
+
try {
|
|
139
|
+
existingApp.unmount();
|
|
140
|
+
} catch (error) {
|
|
141
|
+
console.warn('Failed to unmount existing Vue app:', error);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const app = createApp(Component, props);
|
|
145
|
+
app.config.errorHandler = (err: Error, _instance: unknown, info: string) => {
|
|
146
|
+
console.error('Vue component error during HMR:', err, info);
|
|
147
|
+
};
|
|
148
|
+
app.mount(island, true);
|
|
149
|
+
this.apps.set(island, app);
|
|
150
|
+
const src = island.dataset.src || '';
|
|
151
|
+
const newComponentId = this.generateComponentId(src);
|
|
152
|
+
this.componentIds.set(island, newComponentId);
|
|
153
|
+
if (hmrRuntime) hmrRuntime.createRecord(newComponentId, Component);
|
|
154
|
+
island.dataset.hydrated = 'true';
|
|
155
|
+
island.dataset.hydrationStatus = 'success';
|
|
156
|
+
} catch (error) {
|
|
157
|
+
console.error('Vue HMR update failed:', error);
|
|
158
|
+
island.dataset.hydrationStatus = 'error';
|
|
159
|
+
throw error;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
override restoreState(island: HTMLElement, state: StateSnapshot): void {
|
|
164
|
+
try {
|
|
165
|
+
super.restoreState(island, state);
|
|
166
|
+
} catch (error) {
|
|
167
|
+
console.warn('Failed to restore Vue state:', error);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
override handleError(island: HTMLElement, error: Error): void {
|
|
172
|
+
console.error('Vue HMR error:', error);
|
|
173
|
+
super.handleError(island, error);
|
|
174
|
+
const errorIndicator = island.querySelector('.hmr-error-indicator');
|
|
175
|
+
if (errorIndicator) {
|
|
176
|
+
const msg = error.message;
|
|
177
|
+
let hint = '';
|
|
178
|
+
if (msg.includes('reactive') || msg.includes('ref'))
|
|
179
|
+
hint = ' (Hint: Check reactive state usage - refs must be accessed with .value)';
|
|
180
|
+
else if (msg.includes('render')) hint = ' (Hint: Check component render function or template for errors)';
|
|
181
|
+
else if (msg.includes('hydration') || msg.includes('mismatch'))
|
|
182
|
+
hint = ' (Hint: Server and client render must match)';
|
|
183
|
+
else if (msg.includes('setup'))
|
|
184
|
+
hint = ' (Hint: Check setup function - it should return render function or object)';
|
|
185
|
+
errorIndicator.textContent = `Vue HMR Error: ${msg}${hint}`;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private extractComponentName(src: string): string {
|
|
190
|
+
const parts = src.split('/');
|
|
191
|
+
const filename = parts.at(-1) ?? '';
|
|
192
|
+
return filename.replace(/\.(vue|tsx?|jsx?)$/, '');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private generateComponentId(src: string): string {
|
|
196
|
+
return src.replaceAll(/[^a-zA-Z0-9]/g, '_');
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private captureReactiveData(island: HTMLElement): Record<string, unknown> | undefined {
|
|
200
|
+
try {
|
|
201
|
+
const vueInstance = (island as unknown as { __vueParentComponent?: unknown }).__vueParentComponent;
|
|
202
|
+
if (vueInstance && typeof vueInstance === 'object') {
|
|
203
|
+
const data = (vueInstance as { data?: Record<string, unknown> }).data;
|
|
204
|
+
if (data && typeof data === 'object') return { ...data };
|
|
205
|
+
}
|
|
206
|
+
} catch (error) {
|
|
207
|
+
console.debug('Could not capture Vue reactive data:', error);
|
|
208
|
+
}
|
|
209
|
+
return undefined;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
unmount(island: HTMLElement): void {
|
|
213
|
+
const app = this.apps.get(island);
|
|
214
|
+
if (app) {
|
|
215
|
+
try {
|
|
216
|
+
app.unmount();
|
|
217
|
+
this.apps.delete(island);
|
|
218
|
+
this.componentIds.delete(island);
|
|
219
|
+
} catch (error) {
|
|
220
|
+
console.warn('Failed to unmount Vue app:', error);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export const vueAdapter = new VueHMRAdapter();
|
package/client/hydration.ts
CHANGED
|
@@ -1,124 +1,124 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Vue Client Hydration
|
|
3
|
-
*
|
|
4
|
-
* Provides client-side hydration for Vue components.
|
|
5
|
-
* Attaches interactivity to server-rendered Vue components.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { createApp } from "vue";
|
|
9
|
-
import type { Component } from "vue";
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Hydrate a Vue component on the client
|
|
13
|
-
*
|
|
14
|
-
* Creates a Vue app instance and mounts it to the container element,
|
|
15
|
-
* hydrating the server-rendered HTML.
|
|
16
|
-
*
|
|
17
|
-
* @param container - DOM element containing server-rendered HTML
|
|
18
|
-
* @param component - Vue component to hydrate
|
|
19
|
-
* @param props - Component props
|
|
20
|
-
*/
|
|
21
|
-
export function hydrate(
|
|
22
|
-
container: Element,
|
|
23
|
-
component: unknown,
|
|
24
|
-
props: Record<string, unknown> = {},
|
|
25
|
-
): void {
|
|
26
|
-
try {
|
|
27
|
-
// Create Vue app with the component and props
|
|
28
|
-
// Type assertion needed because component is dynamically loaded
|
|
29
|
-
const app = createApp(component as Component, props);
|
|
30
|
-
|
|
31
|
-
// Mount and hydrate
|
|
32
|
-
app.mount(container, true);
|
|
33
|
-
} catch (error) {
|
|
34
|
-
console.error("Vue hydration failed:", error);
|
|
35
|
-
throw error;
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Get the hydration script for Vue islands
|
|
41
|
-
*
|
|
42
|
-
* Returns a script that will be injected into the page to handle
|
|
43
|
-
* automatic hydration of Vue islands based on their conditions.
|
|
44
|
-
*
|
|
45
|
-
* @returns Hydration script as a string
|
|
46
|
-
*/
|
|
47
|
-
export function getHydrationScript(): string {
|
|
48
|
-
return `
|
|
49
|
-
import { createApp } from 'vue';
|
|
50
|
-
|
|
51
|
-
// Auto-hydrate all Vue islands
|
|
52
|
-
document.querySelectorAll('[data-framework="vue"]').forEach(async (el) => {
|
|
53
|
-
const src = el.getAttribute('data-src');
|
|
54
|
-
const propsJson = el.getAttribute('data-props') || '{}';
|
|
55
|
-
const condition = el.getAttribute('data-condition') || 'on:client';
|
|
56
|
-
|
|
57
|
-
// Check hydration condition
|
|
58
|
-
if (!shouldHydrate(el, condition)) {
|
|
59
|
-
return;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
try {
|
|
63
|
-
// Dynamic import the component
|
|
64
|
-
const module = await import(src);
|
|
65
|
-
const Component = module.default || module;
|
|
66
|
-
|
|
67
|
-
// Parse props
|
|
68
|
-
const props = JSON.parse(propsJson);
|
|
69
|
-
|
|
70
|
-
// Create and mount Vue app
|
|
71
|
-
const app = createApp(Component, props);
|
|
72
|
-
app.mount(el, true);
|
|
73
|
-
} catch (error) {
|
|
74
|
-
console.error('Failed to hydrate Vue island:', error);
|
|
75
|
-
}
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
function shouldHydrate(element, condition) {
|
|
79
|
-
if (!condition || condition === 'on:client') {
|
|
80
|
-
return true;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
if (condition === 'on:visible') {
|
|
84
|
-
return new Promise((resolve) => {
|
|
85
|
-
const observer = new IntersectionObserver((entries) => {
|
|
86
|
-
if (entries[0].isIntersecting) {
|
|
87
|
-
observer.disconnect();
|
|
88
|
-
resolve(true);
|
|
89
|
-
}
|
|
90
|
-
});
|
|
91
|
-
observer.observe(element);
|
|
92
|
-
});
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
if (condition === 'on:interaction') {
|
|
96
|
-
return new Promise((resolve) => {
|
|
97
|
-
const events = ['click', 'mouseenter', 'focusin', 'touchstart'];
|
|
98
|
-
const handler = () => {
|
|
99
|
-
events.forEach(e => element.removeEventListener(e, handler));
|
|
100
|
-
resolve(true);
|
|
101
|
-
};
|
|
102
|
-
events.forEach(e => element.addEventListener(e, handler, { once: true }));
|
|
103
|
-
});
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
if (condition === 'on:idle') {
|
|
107
|
-
return new Promise((resolve) => {
|
|
108
|
-
if ('requestIdleCallback' in window) {
|
|
109
|
-
requestIdleCallback(() => resolve(true));
|
|
110
|
-
} else {
|
|
111
|
-
setTimeout(() => resolve(true), 200);
|
|
112
|
-
}
|
|
113
|
-
});
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
if (condition.startsWith('media:')) {
|
|
117
|
-
const query = condition.slice(6);
|
|
118
|
-
return window.matchMedia(query).matches;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
return true;
|
|
122
|
-
}
|
|
123
|
-
`;
|
|
124
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Vue Client Hydration
|
|
3
|
+
*
|
|
4
|
+
* Provides client-side hydration for Vue components.
|
|
5
|
+
* Attaches interactivity to server-rendered Vue components.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createApp } from "vue";
|
|
9
|
+
import type { Component } from "vue";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Hydrate a Vue component on the client
|
|
13
|
+
*
|
|
14
|
+
* Creates a Vue app instance and mounts it to the container element,
|
|
15
|
+
* hydrating the server-rendered HTML.
|
|
16
|
+
*
|
|
17
|
+
* @param container - DOM element containing server-rendered HTML
|
|
18
|
+
* @param component - Vue component to hydrate
|
|
19
|
+
* @param props - Component props
|
|
20
|
+
*/
|
|
21
|
+
export function hydrate(
|
|
22
|
+
container: Element,
|
|
23
|
+
component: unknown,
|
|
24
|
+
props: Record<string, unknown> = {},
|
|
25
|
+
): void {
|
|
26
|
+
try {
|
|
27
|
+
// Create Vue app with the component and props
|
|
28
|
+
// Type assertion needed because component is dynamically loaded
|
|
29
|
+
const app = createApp(component as Component, props);
|
|
30
|
+
|
|
31
|
+
// Mount and hydrate
|
|
32
|
+
app.mount(container, true);
|
|
33
|
+
} catch (error) {
|
|
34
|
+
console.error("Vue hydration failed:", error);
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Get the hydration script for Vue islands
|
|
41
|
+
*
|
|
42
|
+
* Returns a script that will be injected into the page to handle
|
|
43
|
+
* automatic hydration of Vue islands based on their conditions.
|
|
44
|
+
*
|
|
45
|
+
* @returns Hydration script as a string
|
|
46
|
+
*/
|
|
47
|
+
export function getHydrationScript(): string {
|
|
48
|
+
return `
|
|
49
|
+
import { createApp } from 'vue';
|
|
50
|
+
|
|
51
|
+
// Auto-hydrate all Vue islands
|
|
52
|
+
document.querySelectorAll('[data-framework="vue"]').forEach(async (el) => {
|
|
53
|
+
const src = el.getAttribute('data-src');
|
|
54
|
+
const propsJson = el.getAttribute('data-props') || '{}';
|
|
55
|
+
const condition = el.getAttribute('data-condition') || 'on:client';
|
|
56
|
+
|
|
57
|
+
// Check hydration condition
|
|
58
|
+
if (!shouldHydrate(el, condition)) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
// Dynamic import the component
|
|
64
|
+
const module = await import(src);
|
|
65
|
+
const Component = module.default || module;
|
|
66
|
+
|
|
67
|
+
// Parse props
|
|
68
|
+
const props = JSON.parse(propsJson);
|
|
69
|
+
|
|
70
|
+
// Create and mount Vue app
|
|
71
|
+
const app = createApp(Component, props);
|
|
72
|
+
app.mount(el, true);
|
|
73
|
+
} catch (error) {
|
|
74
|
+
console.error('Failed to hydrate Vue island:', error);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
function shouldHydrate(element, condition) {
|
|
79
|
+
if (!condition || condition === 'on:client') {
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (condition === 'on:visible') {
|
|
84
|
+
return new Promise((resolve) => {
|
|
85
|
+
const observer = new IntersectionObserver((entries) => {
|
|
86
|
+
if (entries[0].isIntersecting) {
|
|
87
|
+
observer.disconnect();
|
|
88
|
+
resolve(true);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
observer.observe(element);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (condition === 'on:interaction') {
|
|
96
|
+
return new Promise((resolve) => {
|
|
97
|
+
const events = ['click', 'mouseenter', 'focusin', 'touchstart'];
|
|
98
|
+
const handler = () => {
|
|
99
|
+
events.forEach(e => element.removeEventListener(e, handler));
|
|
100
|
+
resolve(true);
|
|
101
|
+
};
|
|
102
|
+
events.forEach(e => element.addEventListener(e, handler, { once: true }));
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (condition === 'on:idle') {
|
|
107
|
+
return new Promise((resolve) => {
|
|
108
|
+
if ('requestIdleCallback' in window) {
|
|
109
|
+
requestIdleCallback(() => resolve(true));
|
|
110
|
+
} else {
|
|
111
|
+
setTimeout(() => resolve(true), 200);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (condition.startsWith('media:')) {
|
|
117
|
+
const query = condition.slice(6);
|
|
118
|
+
return window.matchMedia(query).matches;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
`;
|
|
124
|
+
}
|
package/client/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Vue Integration Client Entrypoint
|
|
3
|
-
*
|
|
4
|
-
* Main export for client-side Vue integration functionality.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
export { hydrate, getHydrationScript } from "./hydration.ts";
|
|
1
|
+
/**
|
|
2
|
+
* Vue Integration Client Entrypoint
|
|
3
|
+
*
|
|
4
|
+
* Main export for client-side Vue integration functionality.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export { hydrate, getHydrationScript } from "./hydration.ts";
|