Skip to content

feat: show SSR output #343

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jun 13, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/Repl.vue
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export interface Props {
autoResize?: boolean
showCompileOutput?: boolean
showImportMap?: boolean
showSsrOutput?: boolean
showTsConfig?: boolean
clearConsole?: boolean
layout?: 'horizontal' | 'vertical'
Expand Down Expand Up @@ -54,6 +55,7 @@ const props = withDefaults(defineProps<Props>(), {
autoResize: true,
showCompileOutput: true,
showImportMap: true,
showSsrOutput: false,
showTsConfig: true,
clearConsole: true,
layoutReverse: false,
Expand Down Expand Up @@ -105,6 +107,7 @@ defineExpose({ reload })
ref="output"
:editor-component="editor"
:show-compile-output="props.showCompileOutput"
:show-ssr-output="props.showSsrOutput"
:ssr="!!props.ssr"
/>
</template>
Expand Down
43 changes: 27 additions & 16 deletions src/output/Output.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<script setup lang="ts">
import Preview from './Preview.vue'
import { computed, inject, useTemplateRef } from 'vue'
import SsrOutput from './SsrOutput.vue'
import { computed, inject, useTemplateRef, watchEffect } from 'vue'
import {
type EditorComponentType,
type OutputModes,
Expand All @@ -10,27 +11,32 @@ import {
const props = defineProps<{
editorComponent: EditorComponentType
showCompileOutput?: boolean
showSsrOutput?: boolean
ssr: boolean
}>()

const { store } = inject(injectKeyProps)!
const previewRef = useTemplateRef('preview')
const modes = computed(() =>
props.showCompileOutput
? (['preview', 'js', 'css', 'ssr'] as const)
: (['preview'] as const),
)
const modes = computed(() => {
const outputModes: OutputModes[] = ['preview']
if (props.showCompileOutput) {
outputModes.push('js', 'css', 'ssr')
}
if (props.ssr && props.showSsrOutput) {
outputModes.push('ssr output')
}
return outputModes
})

const mode = computed<OutputModes>({
get: () =>
(modes.value as readonly string[]).includes(store.value.outputMode)
? store.value.outputMode
: 'preview',
set(value) {
if ((modes.value as readonly string[]).includes(store.value.outputMode)) {
store.value.outputMode = value
}
},
get: () => store.value.outputMode,
set: (value) => (store.value.outputMode = value),
})

watchEffect(() => {
if (!modes.value.includes(mode.value)) {
mode.value = modes.value[0]
}
Comment on lines +32 to +39
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I switched to using watchEffect because I wanted the value in the store to be updated if the current tab isn't valid. This can happen if SSR mode is turned off when the SSR OUTPUT tab is active. Turning SSR mode back on should stay on the newly active tab, rather than jumping back to SSR OUTPUT.

})

function reload() {
Expand All @@ -54,8 +60,13 @@ defineExpose({ reload, previewRef })

<div class="output-container">
<Preview ref="preview" :show="mode === 'preview'" :ssr="ssr" />
<SsrOutput
v-if="mode === 'ssr output'"
:context="store.ssrOutput.context"
:html="store.ssrOutput.html"
/>
<props.editorComponent
v-if="mode !== 'preview'"
v-else-if="mode !== 'preview'"
readonly
:filename="store.activeFile.filename"
:value="store.activeFile.compiled[mode]"
Expand Down
31 changes: 29 additions & 2 deletions src/output/Sandbox.vue
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,8 @@ async function updatePreview() {
console.info(
`[@vue/repl] successfully compiled ${ssrModules.length} modules for SSR.`,
)
await proxy.eval([
store.value.ssrOutput.html = store.value.ssrOutput.context = ''
const response = await proxy.eval([
`const __modules__ = {};`,
...ssrModules,
`import { renderToString as _renderToString } from 'vue/server-renderer'
Expand All @@ -235,15 +236,41 @@ async function updatePreview() {
app.config.unwrapInjectedRef = true
}
app.config.warnHandler = () => {}
window.__ssr_promise__ = _renderToString(app).then(html => {
const rawContext = {}
window.__ssr_promise__ = _renderToString(app, rawContext).then(html => {
document.body.innerHTML = '<div id="app">' + html + '</div>' + \`${
previewOptions.value?.bodyHTML || ''
}\`
const safeContext = {}
const isSafe = (v) =>
v === null ||
typeof v === 'boolean' ||
typeof v === 'string' ||
Number.isFinite(v)
const toSafe = (v) => (isSafe(v) ? v : '[' + typeof v + ']')
for (const prop in rawContext) {
const value = rawContext[prop]
safeContext[prop] = isSafe(value)
? value
: Array.isArray(value)
? value.map(toSafe)
: typeof value === 'object'
? Object.fromEntries(
Object.entries(value).map(([k, v]) => [k, toSafe(v)]),
)
: toSafe(value)
}
return { ssrHtml: html, ssrContext: safeContext }
Comment on lines +244 to +263
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While this looks like a lot, it's just trying to make the SSR context object safe to pass to postMessage without losing too much information.

}).catch(err => {
console.error("SSR Error", err)
})
`,
])

if (response) {
store.value.ssrOutput.html = String((response as any).ssrHtml ?? '')
store.value.ssrOutput.context = (response as any).ssrContext || ''
}
}

// compile code to simulated module system
Expand Down
32 changes: 32 additions & 0 deletions src/output/SsrOutput.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<script setup lang="ts">
defineProps<{
html: string
context: unknown
}>()
</script>

<template>
<div class="ssr-output">
<strong>HTML</strong>
<pre class="ssr-output-pre">{{ html }}</pre>
<strong>Context</strong>
<pre class="ssr-output-pre">{{ context }}</pre>
</div>
</template>

<style scoped>
.ssr-output {
background: var(--bg);
box-sizing: border-box;
color: var(--text-light);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In light mode the text is a bit lighter than I would like, but there didn't seem to be any other variables available I could use and I was reluctant to introduce a new one.

height: 100%;
overflow: auto;
padding: 10px;
width: 100%;
}

.ssr-output-pre {
font-family: var(--font-code);
white-space: pre-wrap;
}
</style>
9 changes: 7 additions & 2 deletions src/output/srcdoc.html
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@
const send_message = (payload) =>
parent.postMessage({ ...payload }, ev.origin)
const send_reply = (payload) => send_message({ ...payload, cmd_id })
const send_ok = () => send_reply({ action: 'cmd_ok' })
const send_ok = (response) =>
send_reply({ action: 'cmd_ok', args: response })
const send_error = (message, stack) =>
send_reply({ action: 'cmd_error', message, stack })

Expand Down Expand Up @@ -65,7 +66,11 @@
scriptEls.push(scriptEl)
await done
}
send_ok()
if (window.__ssr_promise__) {
send_ok(await window.__ssr_promise__)
} else {
send_ok()
}
Comment on lines +69 to +73
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a bit of a hack, reusing __ssr_promise__ as a way to get the response value. I couldn't see a better alternative. I could have renamed the promise to something more generic, so it wasn't explicitly tied to SSR, but I figured that could be changed later if other use cases like this arise.

} catch (e) {
send_error(e.message, e.stack)
}
Expand Down
6 changes: 6 additions & 0 deletions src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,7 @@ export function useStore(
showOutput,
outputMode,
sfcOptions,
ssrOutput: { html: '', context: '' },
compiler,
loading,
vueVersion,
Expand Down Expand Up @@ -429,6 +430,10 @@ export type StoreState = ToRefs<{
showOutput: boolean
outputMode: OutputModes
sfcOptions: SFCOptions
ssrOutput: {
html: string
context: unknown
}
/** `@vue/compiler-sfc` */
compiler: typeof defaultCompiler
/* only apply for compiler-sfc */
Expand Down Expand Up @@ -474,6 +479,7 @@ export type Store = Pick<
| 'showOutput'
| 'outputMode'
| 'sfcOptions'
| 'ssrOutput'
| 'compiler'
| 'vueVersion'
| 'locale'
Expand Down
2 changes: 1 addition & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export interface EditorEmits {
}
export type EditorComponentType = Component<EditorProps>

export type OutputModes = 'preview' | EditorMode
export type OutputModes = 'preview' | 'ssr output' | EditorMode

export const injectKeyProps: InjectionKey<
ToRefs<Required<Props & { autoSave: boolean }>>
Expand Down
1 change: 1 addition & 0 deletions test/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ const App = {
editor: MonacoEditor,
// layout: 'vertical',
ssr: true,
showSsrOutput: true,
sfcOptions: {
script: {
// inlineTemplate: false
Expand Down