Should computed track nested refs?
#14462
-
|
I found nesting a
As mentioned in #7873 and #6994, this is not the intended usage of the api, but i found it quit useful, and is working as intended on the client side. A minimal reproduction is at https://stackblitz.com/github/illusionaries/ssr-computed, I will also paste the core code here: <script setup lang="ts">
import { computed, onMounted, onServerPrefetch, ref } from "vue";
const foo = computed(() => {
console.log("executing computed");
let num = ref(0);
const modifyPromise = new Promise<void>((resolve) => {
setTimeout(() => {
num.value++;
resolve();
}, 1000);
});
return {
num,
modifyPromise,
};
});
onServerPrefetch(() => foo.value.modifyPromise);
onMounted(async () => {
await foo.value.modifyPromise;
});
</script>
<template>
<span>{{ foo.num }}</span>
</template>But I noticed some inconsistencies between client build and SSR build, even between different versions of vue. On the client side, regardless of the version, the computed is executed only once, and num is updated from 0 to 1 after 1 second, which is expected. On the SSR side, with vue latest (3.5.28), the computed got executed twice, and the rendered result is I haven't dug into the source code yet, but I suppose it has something to do with the Is the inconsistency expected or is it actually a bug? If it's expected, is there any recommended way to achieve the same effect without nesting a I have tried to use |
Beta Was this translation helpful? Give feedback.
Replies: 3 comments 3 replies
-
|
@aeharding Computed properties automatically unwrap top-level return {
component,
outline: outline.value
} |
Beta Was this translation helpful? Give feedback.
-
|
I would not rely on this pattern. A In your example, the getter does all three: const foo = computed(() => {
const num = ref(0);
const modifyPromise = new Promise<void>((resolve) => {
setTimeout(() => {
num.value++;
resolve();
}, 1000);
});
return {
num,
modifyPromise,
};
});That makes the result dependent on when Vue decides to evaluate or re evaluate the computed. In SSR this is especially fragile because server rendering, A safer shape is to make the state stable outside the computed, and use const outline = ref<string[] | null>(null);
const component = shallowRef<Component | null>(null);
const pending = shallowRef<Promise<void> | null>(null);
watchEffect(() => {
const pageModule = modules[route.path]() as Promise<Page>;
component.value = defineAsyncComponent(() => pageModule);
pending.value = pageModule.then((page) => {
outline.value = page.__outline;
});
});
onServerPrefetch(async () => {
await pending.value;
});Or return a single resolved page data object from an async loader and derive from that: const page = shallowRef<Page | null>(null);
async function loadPage(path: string) {
page.value = await modules[path]();
}
watch(
() => route.path,
(path) => {
void loadPage(path);
},
{ immediate: true },
);
onServerPrefetch(async () => {
await loadPage(route.path);
});
const outline = computed(() => page.value?.__outline ?? null);
const component = computed(() =>
page.value
? defineAsyncComponent(() => Promise.resolve(page.value!))
: null,
);The key point is that the ref identity should be stable. The computed can read For your blog use case, I would model it as “route selects a page module, loader resolves the module, computed values read the resolved module.” That gives SSR a concrete promise to wait for and avoids depending on nested refs inside a computed return value. So my answer would be: the inconsistency is not something I would depend on or design around. The recommended fix is to move the async work and mutable refs out of the computed, then use |
Beta Was this translation helpful? Give feedback.
-
|
This is not a bug you should design around — it's undefined behavior being exposed by an unsupported pattern. A Why it behaves differently in SSR vs client: When the inner The difference between 3.5 and 3.6 beta is an internal implementation detail, not a fix. The fix — move async work and mutable state out of the computed: const outline = ref<string[] | null>(null);
const component = shallowRef<Component | null>(null);
const loadPromise = shallowRef<Promise<void>>();
watchEffect(() => {
const pageModule = modules[route.path]() as Promise<Page>;
component.value = defineAsyncComponent(() => pageModule);
loadPromise.value = pageModule.then((page) => {
outline.value = page.__outline;
});
});
onServerPrefetch(() => loadPromise.value);This way:
For your blog, model it as: route selects module → loader resolves it → computed reads the resolved data. Keep the async and the reactive state separate. |
Beta Was this translation helpful? Give feedback.
I would not rely on this pattern. A
computedgetter should be pure: it should derive and return a value from existing reactive state, not create new refs, start promises, or mutate state later from inside the getter.In your example, the getter does all three:
That makes the result dependent on when Vue decides to evaluate or re evaluate the computed. In SSR this is especially fragile because server rendering,
onServerPrefetch, async dependency resolution,…