Skip to content
This repository was archived by the owner on Dec 5, 2024. It is now read-only.
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
1 change: 1 addition & 0 deletions docs/.vuepress/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ module.exports = {
'/helpers/useContext',
'/helpers/useFetch',
'/helpers/useMeta',
'/helpers/useStatic',
],
},
{
Expand Down
44 changes: 44 additions & 0 deletions docs/helpers/useStatic.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
---
---

# `useStatic`

You can pre-run expensive functions using `useStatic`.

```ts
import { defineComponent, useContext, useStatic, computed } from 'nuxt-composition-api'
import axios from 'axios'

export default defineComponent({
setup() {
const { params } = useContext()
const id = computed(() => params.value.id)
const post = useStatic(
id => axios.get(`https://jsonplaceholder.typicode.com/posts/${id}`),
id,
'post'
)

return { post }
},
})
```

## SSG
If you are generating the whole app (or just prerendering some routes with `nuxt build && nuxt generate --no-build`) the following behaviour will be unlocked:

* On generate, the result of a `useStatic` call will be saved to a JSON file and copied into the `/dist` directory.
* On hard-reload of a generated page, the JSON will be inlined into the page and cached.
* On client navigation to a generated page, this JSON will be fetched - and once fetched it will be cached for subsequent navigations. If for whatever reason this JSON doesn't exist, such as if the page *wasn't* pre-generated, the original factory function will be run on client-side.

::: warning
If you are pregenerating some pages in your app note that you may need to increase `generate.interval`. (See [setup instructions](/setup.html).)
:::

## SSR
If the route is not pre-generated (including in dev mode), then:

* On a hard-reload, the server will run the factory function and inline the result in `nuxtState` - so the client won't rerun the API request. The result will be cached between requests.
* On client navigation, the client will run the factory function.

In both of these cases, the return result of `useStatic` is a `null` ref that is filled with the result of the factory function or JSON fetch when it resolves.
11 changes: 10 additions & 1 deletion now.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
"src": "docs/package.json",
"use": "@now/static-build"
},
{
"src": "test/fixture/api/posts.js",
"use": "@now/node"
},
{
"src": "package.json",
"use": "@now/static-build"
Expand All @@ -13,5 +17,10 @@
"use": "@now/static-build"
}
],
"routes": [{ "handle": "filesystem" }, { "src": "/(.*)", "dest": "/docs/$1" }]
"routes": [
{ "src": "/api/posts/.*", "dest": "/test/fixture/api/posts.js" },
{ "handle": "filesystem" },
{ "src": "/fixture/(.*)", "dest": "/fixture/200.html" },
{ "src": "/(.*)", "dest": "/docs/$1" }
]
}
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
"compile": "rollup -c",
"fixture": "nuxt -c test/fixture/nuxt.config.js",
"fixture:prod": "rm -rf .nuxt && nuxt build -c test/fixture/nuxt.config.js && nuxt start -c test/fixture/nuxt.config.js",
"fixture:generate": "rm -rf .nuxt && nuxt generate -c test/fixture/nuxt.config.js && yarn http-server -p 3000 dist",
"fixture:generate": "rm -rf .nuxt && nuxt generate -c test/fixture/nuxt.config.js && yarn http-server -s -p 3000 dist",
"lint": "run-s lint:all:*",
"lint:all:eslint": "yarn lint:eslint --ext .js,.ts,.vue .",
"lint:all:prettier": "yarn lint:prettier \"**/*.{js,json,ts,vue}\"",
Expand All @@ -44,7 +44,7 @@
"test": "run-s test:*",
"test:e2e-globals": "GLOBALS=true start-server-and-test fixture:prod 3000 \"testcafe firefox:headless test/e2e\"",
"test:e2e-ssr": "start-server-and-test fixture:prod 3000 \"testcafe firefox:headless test/e2e\"",
"test:e2e-generated": "start-server-and-test fixture:generate 3000 \"testcafe firefox:headless test/e2e\"",
"test:e2e-generated": "start-server-and-test fixture:generate 3000 \"GENERATE=true testcafe firefox:headless test/e2e\"",
"test:types": "tsd",
"test:unit": "jest"
},
Expand Down
29 changes: 21 additions & 8 deletions src/babel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,17 +23,30 @@ export default function ssrRefPlugin({ loadOptions, getEnv, types: t }: Babel) {
}
: {}),
CallExpression(path) {
if (
!('name' in path.node.callee) ||
!['ssrRef', 'useAsync', 'shallowSsrRef'].includes(path.node.callee.name)
)
return
if (!('name' in path.node.callee)) return

if (path.node.arguments.length > 1) return
const hash = crypto.createHash('md5')
let method: crypto.HexBase64Latin1Encoding = 'base64'

switch (path.node.callee.name) {
case 'useStatic':
if (path.node.arguments.length > 2) return
if (path.node.arguments.length === 2) path.node.arguments.push()
method = 'hex'
break

case 'ssrRef':
case 'shallowSsrRef':
case 'useAsync':
if (path.node.arguments.length > 1) return
break

default:
return
}

const hash = crypto.createHash('md5')
hash.update(`${cwd}-${path.node.callee.start}`)
const digest = hash.digest('base64').toString()
const digest = hash.digest(method).toString()
path.node.arguments.push(t.stringLiteral(`${varName}${digest}`))
},
}
Expand Down
1 change: 1 addition & 0 deletions src/entrypoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export { useContext, withContext } from './context'
export { useFetch } from './fetch'
export { useMeta } from './meta'
export { ssrRef, shallowSsrRef, setSSRContext } from './ssr-ref'
export { useStatic } from './static'

export type {
ComponentRenderProxy,
Expand Down
18 changes: 18 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { resolve, join } from 'path'
import { rmdirSync, readdirSync, copyFileSync, existsSync, mkdirSync } from 'fs'

import type { Module } from '@nuxt/types'

Expand All @@ -22,6 +23,21 @@ const compositionApiModule: Module<any> = function () {
},
})

const staticPath = join(this.options.buildDir || '', 'static-json')

this.nuxt.hook('build:compile', () => {
if (existsSync(staticPath)) rmdirSync(staticPath)
mkdirSync(staticPath)
})

this.nuxt.hook('generate:done', async (generator: any) => {
const srcDir = join(this.options.buildDir || '', 'static-json')
const { distPath } = generator
readdirSync(srcDir).forEach(file =>
copyFileSync(join(srcDir, file), join(distPath, file))
)
})

const globalName = this.options.globalName || 'nuxt'
const globalContextFactory =
this.options.globals?.context ||
Expand All @@ -35,6 +51,8 @@ const compositionApiModule: Module<any> = function () {
src: resolve(libRoot, 'lib', 'entrypoint.js'),
fileName: join('composition-api', 'index.js'),
options: {
staticPath,
publicPath: join(this.options.router?.base || '', '/'),
globalContext,
globalNuxt,
},
Expand Down
118 changes: 118 additions & 0 deletions src/static.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { ssrRef } from './ssr-ref'
import { Ref, onServerPrefetch, watch, computed } from '@vue/composition-api'

const staticPath = '<%= options.staticPath %>'
const staticCache: Record<string, any> = {}

async function writeFile(key: string) {
if (process.client || !process.static) return

const { writeFileSync }: typeof import('fs') = process.client
? ''
: require('fs')
const { join }: typeof import('path') = process.client ? '' : require('path')

try {
writeFileSync(
join(staticPath, `${key}.json`),
JSON.stringify(staticCache[key])
)
} catch (e) {
console.log(e)
}
}
/**
* You can pre-run expensive functions using `useStatic`.
*
* __SSG__
* If you are generating the whole app (or just prerendering some routes with `nuxt build && nuxt generate --no-build`) the following behaviour will be unlocked:

1. On generate, the result of a `useStatic` call will be saved to a JSON file and copied into the `/dist` directory.
2. On hard-reload of a generated page, the JSON will be inlined into the page and cached.
3. On client navigation to a generated page, this JSON will be fetched - and once fetched it will be cached for subsequent navigations. If for whatever reason this JSON doesn't exist, such as if the page *wasn't* pre-generated, the original factory function will be run on client-side.

If you are pregenerating some pages in your app note that you may need to increase `generate.interval`. (See [setup instructions](https://composition-api.now.sh/setup.html).)

*
* __SSR__
* If the route is not pre-generated (including in dev mode), then:

1. On a hard-reload, the server will run the factory function and inline the result in `nuxtState` - so the client won't rerun the API request. The result will be cached between requests.
2. On client navigation, the client will run the factory function.

In both of these cases, the return result of `useStatic` is a `null` ref that is filled with the result of the factory function or JSON fetch when it resolves.

* @param factory The async function that will populate the ref this function returns. It receives the param and keyBase (see below) as parameters.
* @param param A an optional param (such as an ID) to distinguish multiple API fetches using the same factory function.
* @param keyBase A key that should be unique across your project. If not provided, this will be auto-generated by `nuxt-composition-api`.
* @example
```ts
import { defineComponent, useContext, useStatic, computed } from 'nuxt-composition-api'
import axios from 'axios'

export default defineComponent({
setup() {
const { params } = useContext()
const id = computed(() => params.value.id)
const post = useStatic(
id => axios.get(`https://jsonplaceholder.typicode.com/posts/${id}`),
id,
'post'
)

return { post }
},
})
```
*/
export const useStatic = <T>(
factory: (param: string, key: string) => Promise<T>,
param: Ref<string> = { value: '' },
keyBase: string
): Ref<T | null> => {
const key = computed(() => `${keyBase}-${param.value}`)
const result = ssrRef<T | null>(null, key.value)

if (result.value) staticCache[key.value] = result.value

if (process.client) {
const onFailure = () =>
factory(param.value, key.value).then(r => {
staticCache[key.value] = r
result.value = r
return
})
watch(key, key => {
if (key in staticCache) {
result.value = staticCache[key]
return
}
/* eslint-disable promise/always-return */
if (!process.static) onFailure()
else
fetch(`<%= options.publicPath %>${key}.json`)
.then(response => {
if (!response.ok) throw new Error('Response invalid.')
return response.json()
})
.then(json => {
staticCache[key] = json
result.value = json
})
.catch(onFailure)
/* eslint-enable */
})
} else {
if (key.value in staticCache) {
result.value = staticCache[key.value]
return result
}
onServerPrefetch(async () => {
result.value = await factory(param.value, key.value)
staticCache[key.value] = result.value
writeFile(key.value)
})
}

return result
}
37 changes: 36 additions & 1 deletion test/e2e/helpers/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { t, Selector, ClientFunction } from 'testcafe'
import { t, Selector, ClientFunction, RequestLogger } from 'testcafe'

const url = `http://localhost:3000`
export function navigateTo(path: string) {
Expand All @@ -20,3 +20,38 @@ export const getWindowPathname = ClientFunction(() => window.location.pathname)
export function expectPathnameToBe(pathname: string) {
return t.expect(getWindowPathname()).eql(pathname)
}
export function getLogger(
filter?: Parameters<typeof RequestLogger>[0],
options?: Parameters<typeof RequestLogger>[1]
) {
const logger = RequestLogger(filter, options)

async function waitForFirstRequest(
condition: Parameters<RequestLogger['contains']>[0] = () => true
) {
for (let i = 0; i < 50; i++) {
await t.wait(100)
if (await logger.contains(condition)) return
}
}

async function expectToBeCalled() {
await waitForFirstRequest()
await t.expect(logger.requests.length).gte(1)
return t
}

async function expectToBeCalledWith(
condition: Parameters<RequestLogger['contains']>[0]
) {
await waitForFirstRequest(condition)
await t.expect(await logger.contains(condition)).ok()
return t
}

return {
logger,
expectToBeCalled,
expectToBeCalledWith,
}
}
49 changes: 49 additions & 0 deletions test/e2e/static.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { Selector } from 'testcafe'
import {
navigateTo,
expectOnPage,
getLogger,
} from './helpers'

const apiLogger = getLogger(/posts/)

const url = (id: string | number) => process.env.GENERATE ? `http://localhost:3000/posts-${id}.json` : `http://localhost:3000/api/posts/${id}`

// eslint-disable-next-line
fixture`useStatic`.beforeEach(async t => {
await t.addRequestHooks(apiLogger.logger)
apiLogger.logger.clear()}
)

test('Shows data on ssr-loaded page', async t => {
await navigateTo('/static/1')
await expectOnPage('"id": "1"')
const count = await apiLogger.logger.count(Boolean)
await t.expect(count).eql(0)

await t.click(Selector('a').withText('home'))
await t.click(Selector('a').withText('static'))
await expectOnPage('"id": "1"')
const newCount = await apiLogger.logger.count(Boolean)
await t.expect(newCount).eql(count)
})

test('Shows data on non-generated page', async t => {
await navigateTo('/static/3')
apiLogger.logger.clear()
await t.click(Selector('a').withText('Next'))
await t.expect(apiLogger.logger.count(Boolean)).eql(process.env.GENERATE ? 2 : 1)
})

test('Shows appropriate data on client-loaded page', async t => {
await navigateTo('/')
await t.click(Selector('a').withText('static'))
await expectOnPage('"id": "1"')
await t.expect(apiLogger.logger.count(Boolean)).eql(1)
await apiLogger.expectToBeCalledWith(r => r.request.url === url(1))

await t.click(Selector('a').withText('Next'))
await expectOnPage('"id": "2"')
await t.expect(apiLogger.logger.count(Boolean)).eql(2)
await apiLogger.expectToBeCalledWith(r => r.request.url === url(2))
})
3 changes: 3 additions & 0 deletions test/fixture/api/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"name": "api"
}
Loading