Skip to content

Commit

Permalink
feat: add ssrRef capability for automatic SSR support (#23)
Browse files Browse the repository at this point in the history
  • Loading branch information
danielroe authored May 4, 2020
1 parent 553ff09 commit f27fae8
Show file tree
Hide file tree
Showing 11 changed files with 277 additions and 2 deletions.
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@

- 🏃 **Fetch**: Support for the new Nuxt `fetch()` in v2.12+
- ℹ️ **Context**: Easy access to `router`, `app`, `store` within `setup()`
-**Automatic hydration**: Drop-in replacement for `ref` with automatic SSR stringification and hydration (`ssrRef`)
- 📝 **SSR support**: Allows using the Composition API with SSR
- 💪 **TypeScript**: Written in TypeScript

Expand Down Expand Up @@ -92,6 +93,30 @@ export default defineComponent({

**Note**: `useFetch` must be called synchronously within `setup()`. Any changes made to component data - that is, to properties _returned_ from `setup()` - will be sent to the client and directly loaded. Other side-effects of `useFetch` hook will not be persisted.

### ssrRef

When creating composition utility functions, often there will be server-side state that needs to be conveyed to the client.

`ssrRef` will automatically add ref values to `window.__NUXT__` on SSR if they have been changed from their initial value. It can be used outside of components, such as in shared utility functions, and it supports passing a factory function that will generate the initial value of the ref.

If you are using `onServerPrefetch` together with `ssrRef`, make sure you are using the version of `onServerPrefetch` exported by this package. (Otherwise, changes made in the `onServerPrefetch` lifecycle hook may not be stringified.)

```ts
import { ssrRef } from 'nuxt-composition-api'

const val = ssrRef('')

// When hard-reloaded, `val` will be initialised to 'server set'
if (process.server) val.value = 'server set'

// When hard-reloaded, the result of myExpensiveSetterFunction() will
// be encoded in nuxtState and used as the initial value of this ref.
// If client-loaded, the setter function will run to come up with initial value.
const val2 = ssrRef(myExpensiveSetterFunction)
```

**Note**: Under the hood, `ssrRef` requires a key to ensure that the ref values match between client and server. If you have added `nuxt-composition-api` to your `buildModules`, this will be done automagically by an injected Babel plugin. If you need to do things differently, you can specify a key manually or add `nuxt-composition-api/babel` to your Babel plugins.

### withContext

You can access the Nuxt context more easily using `withContext`, which will immediately call the callback and pass it the Nuxt context.
Expand Down
21 changes: 21 additions & 0 deletions rollup.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,25 @@ export default [
...Object.keys(pkg.peerDependencies || {}),
],
},
{
input: 'src/babel.ts',
output: [
{
file: 'lib/babel.js',
format: 'cjs',
},
{
file: 'lib/babel.es.js',
format: 'es',
},
],
plugins: [
typescript({
typescript: require('typescript'),
tsconfigOverride: {
compilerOptions: { declaration: false },
},
}),
],
},
]
37 changes: 37 additions & 0 deletions src/babel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import * as types from '@babel/types'
import { Visitor } from '@babel/traverse'
import crypto from 'crypto'

interface Babel {
types: typeof types
loadOptions: () => Record<string, any>
getEnv: () => string
}

export default function ssrRefPlugin({ loadOptions, getEnv, types: t }: Babel) {
const env = getEnv()
const cwd = env === 'test' ? '' : loadOptions().cwd

let varName = ''
const visitor: Visitor = {
...(env !== 'production'
? {
VariableDeclarator(path) {
varName = 'name' in path.node.id ? `${path.node.id.name}-` : ''
},
}
: {}),
CallExpression(path) {
if (!('name' in path.node.callee) || path.node.callee.name !== 'ssrRef')
return

if (path.node.arguments.length > 1) return
const hash = crypto.createHash('md5')

hash.update(`${cwd}-${path.node.callee.start}`)
const digest = hash.digest('base64').toString()
path.node.arguments.push(t.stringLiteral(`${varName}${digest}`))
},
}
return { visitor }
}
40 changes: 39 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ const compositionApiModule: Module<any> = function () {
fileName: join('composition-api', 'plugin.js'),
options: {},
})

this.options.build = this.options.build || {}
this.options.build.babel = this.options.build.babel || {}
this.options.build.babel.plugins = this.options.build.babel.plugins || []
this.options.build.babel.plugins.push(join(__dirname, 'babel'))

this.options.plugins = this.options.plugins || []
this.options.plugins.push(resolve(this.options.buildDir || '', dst))
}
Expand All @@ -18,5 +24,37 @@ export const meta = require('../package.json')

export { useFetch } from './fetch'
export { withContext } from './context'
export { ssrRef, onServerPrefetch } from './ssr-ref'

export * from '@vue/composition-api'
export {
ComponentRenderProxy,
InjectionKey,
PropOptions,
PropType,
Ref,
SetupContext,
VueWatcher,
computed,
createComponent,
createElement,
defineComponent,
getCurrentInstance,
inject,
isRef,
onActivated,
onBeforeMount,
onBeforeUnmount,
onBeforeUpdate,
onDeactivated,
onErrorCaptured,
onMounted,
onUnmounted,
onUpdated,
provide,
reactive,
ref,
set,
toRefs,
watch,
watchEffect,
} from '@vue/composition-api'
56 changes: 56 additions & 0 deletions src/ssr-ref.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import {
ref,
onServerPrefetch as prefetch,
getCurrentInstance,
Ref,
} from '@vue/composition-api'

function getValue(value: any) {
if (typeof value === 'function') return value()
return value
}

let ssrContext: any

const refs: [string, Ref<any>][] = []

export function injectRefs() {
if (!process.server) return

if (!ssrContext.nuxt.ssrRefs) ssrContext.nuxt.ssrRefs = {}

refs.forEach(([key, ref]) => {
ssrContext.nuxt.ssrRefs[key] = ref.value
})
}

export const ssrRef = <T>(value: T, key?: string) => {
const val = ref<T>(getValue(value))
const vm = getCurrentInstance()!

if (!key)
throw new Error(
"You must provide a key. You can have it generated automatically by adding 'nuxt-composition-api/babel' to your Babel plugins."
)

if (!ssrContext) {
ssrContext = ssrContext || vm.$ssrContext
prefetch(injectRefs)
}

if (process.client) {
const nuxtState = (window as any).__NUXT__
val.value = (nuxtState.ssrRefs || {})[key!] ?? getValue(value)
} else {
refs.push([key, val])
}

return val
}

export const onServerPrefetch = (callback: Function) => {
prefetch(async () => {
await callback()
injectRefs()
})
}
29 changes: 29 additions & 0 deletions test/e2e/ssr-refs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { Selector } from 'testcafe'
import {
navigateTo,
expectOnPage,
expectPathnameToBe,
expectNotOnPage,
} from './helpers'

// eslint-disable-next-line
fixture`SSR Refs`

test('Shows data on ssr-loaded page', async t => {
await navigateTo('/ssr-ref')
await expectOnPage('ref-only SSR rendered')
await expectOnPage('function-runs SSR or client-side')
await expectOnPage('prefetched-result')

await t.click(Selector('a').withText('home'))
await t.click(Selector('a').withText('ssr refs'))
await expectOnPage('ref-only SSR rendered')
})

test('Shows appropriate data on client-loaded page', async t => {
await navigateTo('/')
await t.click(Selector('a').withText('ssr refs'))
await expectPathnameToBe('/ssr-ref')
await expectNotOnPage('ref-only SSR rendered')
await expectOnPage('function-runs SSR or client-side')
})
1 change: 1 addition & 0 deletions test/fixture/pages/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
<div>{{ computedProp }}</div>
<div>{{ myFunction() }}</div>
<nuxt-link to="/other">link forward</nuxt-link>
<nuxt-link to="/ssr-ref">ssr refs</nuxt-link>
<button @click="$fetch">Refetch</button>
<child-comp />
</div>
Expand Down
42 changes: 42 additions & 0 deletions test/fixture/pages/ssr-ref.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<template>
<div>
<div>ref-{{ computedVal }}</div>
<div>function-{{ funcValue }}</div>
<div>prefetched-{{ prefetchValue }}</div>
<nuxt-link to="/">home</nuxt-link>
</div>
</template>

<script>
import { defineComponent, ref, computed, useFetch, ssrRef, onServerPrefetch } from 'nuxt-composition-api'
export function fetcher(result, time = 100) {
return new Promise(resolve => {
return setTimeout(() => {
resolve(result)
}, time)
})
}
export default defineComponent({
setup() {
const refValue = ssrRef('')
const prefetchValue = ssrRef('')
const funcValue = ssrRef(() => 'runs SSR or client-side')
const computedVal = computed(() => refValue.value)
if (process.server) refValue.value = 'only SSR rendered'
onServerPrefetch(async () => {
prefetchValue.value = await fetcher('result', 500)
})
return {
computedVal,
funcValue,
prefetchValue,
}
},
})
</script>
8 changes: 8 additions & 0 deletions test/unit/__snapshots__/babel-ssr-ref.spec.ts.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`babel plugin works 1`] = `
"const ref = ref(1);
const ref2 = ssrRef(2, \\"ref2-BAW7YFDj4E+Qxr+ujqEADg==\\");
const ref3 = ssrRef(3, 'custom-key');
const ref4 = ssrRef(4, \\"ref4-h6IKM1doqCRBR49lWv2V/g==\\");"
`;
18 changes: 18 additions & 0 deletions test/unit/babel-ssr-ref.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/* eslint-disable @typescript-eslint/no-var-requires */
const babel = require('@babel/core')
const plugin = require('../../lib/babel')
/* eslint-enable */

var example = `
const ref = ref(1)
const ref2 = ssrRef(2)
const ref3 = ssrRef(3, 'custom-key')
const ref4 = ssrRef(4)
`

describe('babel plugin', () => {
it('works', () => {
const { code } = babel.transform(example, { plugins: [plugin] })!
expect(code).toMatchSnapshot()
})
})
2 changes: 1 addition & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"declaration": true,
"skipLibCheck": true,
"allowSyntheticDefaultImports": true,
"types": ["node", "@nuxt/types"]
"types": ["node", "@nuxt/types", "jest"]
},
"exclude": ["node_modules", "lib", "test"]
}

0 comments on commit f27fae8

Please sign in to comment.