Skip to content
This repository was archived by the owner on Jul 6, 2025. It is now read-only.

Commit ed4391d

Browse files
committed
wip(framework/vue): add useDate
1 parent 432dbcb commit ed4391d

File tree

8 files changed

+338
-57
lines changed

8 files changed

+338
-57
lines changed

examples/vue-app/routes/blog.vue

Lines changed: 0 additions & 22 deletions
This file was deleted.

examples/vue-app/routes/hello.vue

Lines changed: 0 additions & 22 deletions
This file was deleted.

examples/vue-app/routes/todos.vue

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
<script setup>
2+
import { Head, useData } from "aleph/vue"
3+
4+
const { data, isMutating, mutation } = useData();
5+
6+
async function onChange(todo) {
7+
const { id } = todo;
8+
const completed = !todo.completed;
9+
mutation.patch({ id, completed }, "replace")
10+
}
11+
12+
async function onSubmit(e) {
13+
e.preventDefault();
14+
const form = e.currentTarget;
15+
const fd = new FormData(form);
16+
const message = fd.get("message")?.toString().trim();
17+
if (message) {
18+
mutation.put({ message }, {
19+
// optimistic update without waiting for the server response
20+
optimisticUpdate: (data) => {
21+
return {
22+
todos: [...data.todos, { id: 0, message, completed: false }],
23+
};
24+
},
25+
// replace the data with the new data from the server
26+
replace: true,
27+
});
28+
form.reset();
29+
setTimeout(() => {
30+
form.querySelector("input")?.focus();
31+
}, 0);
32+
}
33+
}
34+
35+
function onClick(todo) {
36+
mutation.delete({ id: todo.id }, "replace");
37+
}
38+
</script>
39+
40+
<script>
41+
const storage = {
42+
todos: JSON.parse(window.localStorage?.getItem("todos") || "[]"),
43+
};
44+
45+
export const data = {
46+
cacheTtl: 0,
47+
get: (_req, ctx) => {
48+
return ctx.json(storage);
49+
},
50+
put: async (req, ctx) => {
51+
const { message } = await req.json();
52+
if (typeof message === "string") {
53+
const id = Date.now();
54+
storage.todos.push({ id, message, completed: false });
55+
window.localStorage?.setItem("todos", JSON.stringify(storage.todos));
56+
}
57+
return ctx.json(storage);
58+
},
59+
patch: async (req, ctx) => {
60+
const { id, message, completed } = await req.json();
61+
const todo = storage.todos.find((todo) => todo.id === id);
62+
if (todo) {
63+
if (typeof message === "string") {
64+
todo.message = message;
65+
}
66+
if (typeof completed === "boolean") {
67+
todo.completed = completed;
68+
}
69+
window.localStorage?.setItem("todos", JSON.stringify(storage.todos));
70+
}
71+
return ctx.json(storage);
72+
},
73+
delete: async (req, ctx) => {
74+
const { id } = await req.json();
75+
if (id) {
76+
storage.todos = storage.todos.filter((todo) => todo.id !== id);
77+
window.localStorage?.setItem("todos", JSON.stringify(storage.todos));
78+
}
79+
return ctx.json(storage);
80+
},
81+
};
82+
</script>
83+
84+
<template>
85+
<div className="page todos-app">
86+
<Head>
87+
<title>Todos</title>
88+
<meta name="description" content="A todos app powered by Aleph.js" />
89+
</Head>
90+
<h1>
91+
<span>Todos</span>
92+
</h1>
93+
<ul>
94+
<li v-for="todo in data.todos" :key="todo.id">
95+
<input type="checkbox" :checked="todo.completed" @change="onChange(todo)" />
96+
<label :class="todo.completed ? 'completed' : ''">{{ todo.message }}</label>
97+
<button @click="onClick(todo)"></button>
98+
</li>
99+
</ul>
100+
<form @submit="onSubmit">
101+
<input :disabled="!!isMutating" type="text" name="message" placeholder="What needs to be done?"
102+
autofocus="autofocus" autocomplete="off" />
103+
</form>
104+
</div>
105+
</template>

framework/vue/context.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,14 @@ export const RouterContext = ref({
55
params: {},
66
});
77

8-
type DataContextProps = Ref<{
8+
type DataContextProps = {
99
dataUrl: string;
1010
dataCache: Map<any, any>;
1111
ssrHeadCollection?: string[];
12-
}>;
12+
};
1313

14-
export const DataContext: DataContextProps = ref({
14+
export const DataContext: DataContextProps = {
1515
dataUrl: "/",
1616
dataCache: new Map(),
17-
});
17+
ssrHeadCollection: [],
18+
};

framework/vue/data.ts

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import { onBeforeUnmount, Ref, ref, toRaw, watch } from "vue";
2+
import FetchError from "../core/fetch_error.ts";
3+
import { DataContext } from "./context.ts";
4+
5+
type HttpMethod = "get" | "post" | "put" | "patch" | "delete";
6+
7+
type UpdateStrategy<T> = "none" | "replace" | {
8+
optimisticUpdate?: (data: T) => T;
9+
onFailure?: (error: Error) => void;
10+
replace?: boolean;
11+
};
12+
13+
export const useData = <T = unknown>(): {
14+
data: Ref<T>;
15+
isMutating: Ref<HttpMethod | boolean>;
16+
mutation: typeof mutation;
17+
reload: (signal?: AbortSignal) => Promise<void>;
18+
} => {
19+
const { dataUrl, dataCache } = DataContext;
20+
const cached = dataCache.get(dataUrl);
21+
22+
if (cached) {
23+
if (cached.data instanceof Error) {
24+
throw cached.data;
25+
}
26+
if (typeof cached.data === "function") {
27+
const data = cached.data();
28+
if (data instanceof Promise) {
29+
throw data.then((data) => {
30+
cached.data = data;
31+
}).catch((error) => {
32+
cached.data = error;
33+
});
34+
}
35+
throw new Error(`Data for ${dataUrl} has invalid type [function].`);
36+
}
37+
} else {
38+
throw new Error(`Data for ${dataUrl} is not found`);
39+
}
40+
41+
const _data: Ref<T> = ref(cached.data);
42+
const isMutating = ref<HttpMethod | boolean>(false);
43+
44+
const action = async (method: HttpMethod, fetcher: Promise<Response>, update: UpdateStrategy<T>) => {
45+
const updateIsObject = update && typeof update === "object" && update !== null;
46+
const optimistic = updateIsObject && typeof update.optimisticUpdate === "function";
47+
const replace = update === "replace" || (updateIsObject && !!update.replace);
48+
49+
isMutating.value = method;
50+
51+
let rollbackData: T | undefined = undefined;
52+
if (optimistic) {
53+
const optimisticUpdate = update.optimisticUpdate!;
54+
if (_data.value !== undefined) {
55+
rollbackData = toRaw(_data.value);
56+
_data.value = optimisticUpdate(clone(toRaw(_data.value)));
57+
}
58+
}
59+
60+
const res = await fetcher;
61+
if (res.status >= 400) {
62+
if (optimistic) {
63+
if (rollbackData !== undefined) {
64+
_data.value = rollbackData;
65+
}
66+
if (update.onFailure) {
67+
update.onFailure(await FetchError.fromResponse(res));
68+
}
69+
}
70+
isMutating.value = false;
71+
return res;
72+
}
73+
74+
if (res.status >= 300) {
75+
const redirectUrl = res.headers.get("Location");
76+
if (redirectUrl) {
77+
location.href = new URL(redirectUrl, location.href).href;
78+
}
79+
if (optimistic && rollbackData !== undefined) {
80+
_data.value = rollbackData;
81+
}
82+
isMutating.value = false;
83+
return res;
84+
}
85+
86+
if (replace && res.ok) {
87+
try {
88+
const data = await res.json();
89+
const dataCacheTtl = dataCache.get(dataUrl)?.dataCacheTtl;
90+
dataCache.set(dataUrl, { data, dataCacheTtl, dataExpires: Date.now() + (dataCacheTtl || 1) * 1000 });
91+
_data.value = data;
92+
} catch (_) {
93+
if (optimistic) {
94+
if (rollbackData !== undefined) {
95+
_data.value = rollbackData;
96+
}
97+
if (update.onFailure) {
98+
update.onFailure(new FetchError(500, {}, "Data must be valid JSON"));
99+
}
100+
}
101+
}
102+
}
103+
104+
isMutating.value = false;
105+
return res;
106+
};
107+
108+
const reload = async (signal?: AbortSignal) => {
109+
console.log("reload");
110+
111+
try {
112+
const res = await fetch(dataUrl, { headers: { "Accept": "application/json" }, signal, redirect: "manual" });
113+
if (res.type === "opaqueredirect") {
114+
throw new Error("opaque redirect");
115+
}
116+
if (!res.ok) {
117+
throw await FetchError.fromResponse(res);
118+
}
119+
try {
120+
const data = await res.json();
121+
const cc = res.headers.get("Cache-Control");
122+
const dataCacheTtl = cc && cc.includes("max-age=") ? parseInt(cc.split("max-age=")[1]) : undefined;
123+
const dataExpires = Date.now() + (dataCacheTtl || 1) * 1000;
124+
dataCache.set(dataUrl, { data, dataExpires });
125+
_data.value = data;
126+
} catch (_e) {
127+
throw new FetchError(500, {}, "Data must be valid JSON");
128+
}
129+
} catch (error) {
130+
throw new Error(`Failed to reload data for ${dataUrl}: ${error.message}`);
131+
}
132+
};
133+
134+
const mutation = {
135+
post: (data?: unknown, update?: UpdateStrategy<T>) => {
136+
return action("post", send("post", dataUrl, data), update ?? "none");
137+
},
138+
put: (data?: unknown, update?: UpdateStrategy<T>) => {
139+
return action("put", send("put", dataUrl, data), update ?? "none");
140+
},
141+
patch: (data?: unknown, update?: UpdateStrategy<T>) => {
142+
return action("patch", send("patch", dataUrl, data), update ?? "none");
143+
},
144+
delete: (data?: unknown, update?: UpdateStrategy<T>) => {
145+
return action("delete", send("delete", dataUrl, data), update ?? "none");
146+
},
147+
};
148+
149+
watch(() => dataUrl, () => {
150+
const now = Date.now();
151+
const cache = dataCache.get(dataUrl);
152+
let ac: AbortController | null = null;
153+
if (cache === undefined || cache.dataExpires === undefined || cache.dataExpires < now) {
154+
ac = new AbortController();
155+
reload(ac.signal).finally(() => {
156+
ac = null;
157+
});
158+
} else if (cache.data !== undefined) {
159+
_data.value = cache.data as never;
160+
}
161+
162+
onBeforeUnmount(() => ac?.abort());
163+
});
164+
165+
return { data: _data, isMutating, mutation, reload };
166+
};
167+
168+
function send(method: HttpMethod, href: string, data: unknown) {
169+
let body: BodyInit | undefined;
170+
const headers = new Headers();
171+
if (typeof data === "string") {
172+
body = data;
173+
} else if (typeof data === "number") {
174+
body = data.toString();
175+
} else if (typeof data === "object") {
176+
if (data instanceof ArrayBuffer || data instanceof Uint8Array) {
177+
body = data;
178+
} else if (data instanceof FormData) {
179+
body = data;
180+
} else if (data instanceof URLSearchParams) {
181+
body = data;
182+
} else if (data instanceof Blob) {
183+
body = data;
184+
headers.append("Content-Type", data.type);
185+
} else {
186+
body = JSON.stringify(data);
187+
headers.append("Content-Type", "application/json; charset=utf-8");
188+
}
189+
}
190+
return fetch(href, { method, body, headers, redirect: "manual" });
191+
}
192+
193+
function clone<T>(obj: T): T {
194+
// deno-lint-ignore ban-ts-comment
195+
// @ts-ignore
196+
return typeof structuredClone === "function" ? structuredClone(obj) : JSON.parse(JSON.stringify(obj));
197+
}

framework/vue/head.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export const Head = defineComponent({
1818
ssrHeadCollection.push(`<title ssr>${children.join("")}</title>`);
1919
}
2020
}
21-
DataContext.value.ssrHeadCollection = ssrHeadCollection;
21+
DataContext.ssrHeadCollection = ssrHeadCollection;
2222
});
2323
}
2424
},

framework/vue/mod.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export { useData } from "./data.ts";
12
export { App, createApp, createSSRApp } from "./router.ts";
23
export { Link } from "./link.ts";
34
export { Head } from "./head.ts";

0 commit comments

Comments
 (0)