Skip to content
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

feat(nuxt): client only page #25037

Merged
merged 58 commits into from
Mar 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
0b653bc
refactor: allow wraping route component into `ClientOnly`
logotip4ik Jan 3, 2024
e3d63de
test: add tests for client only page
logotip4ik Jan 3, 2024
a0e28b9
chore: add `clientOnly` option to `PageMeta` interface
logotip4ik Jan 3, 2024
821a9c8
docs: mention `clientOnly` in definePageMeta
logotip4ik Jan 3, 2024
44e0750
fix ? wrap transition into client only
logotip4ik Jan 3, 2024
2a62ce0
test: type errors array and push only error text
logotip4ik Jan 3, 2024
4a568f8
Update docs/2.guide/2.directory-structure/1.pages.md
logotip4ik Jan 4, 2024
6f69fbc
Update packages/nuxt/src/pages/runtime/composables.ts
logotip4ik Jan 4, 2024
16e2761
Revert "fix ? wrap transition into client only"
logotip4ik Jan 4, 2024
39eb07f
wip rexport page component wrapped in createClientOnly
logotip4ik Jan 4, 2024
7221021
Merge branch 'main' into feat/client-only-page
logotip4ik Jan 14, 2024
d4c58fa
Merge branch 'main' into feat/client-only-page
logotip4ik Jan 18, 2024
b15125a
Reapply "fix ? wrap transition into client only"
logotip4ik Jan 19, 2024
ff16d81
is it really that broken ?
logotip4ik Jan 19, 2024
93bf622
Merge branch 'main' into feat/client-only-page
logotip4ik Jan 19, 2024
eaba14f
chore: invert if check
logotip4ik Jan 19, 2024
51167ff
refactor: reexport page component
logotip4ik Jan 20, 2024
8488b56
fix: export undefined if page source is empty
logotip4ik Jan 20, 2024
ba79c79
fix: only retest for client only page if not seen one yet
logotip4ik Jan 20, 2024
17caed5
fix: check macro page imports for definePageMeta macro
logotip4ik Jan 20, 2024
03703c4
refactor: remove not needed query for page component import
logotip4ik Jan 20, 2024
f8f5d9e
test: add little delay to account for vue transition
logotip4ik Jan 20, 2024
48a37cf
Merge branch 'main' into feat/client-only-page
logotip4ik Jan 20, 2024
0625b61
chore: pretend we are import from a file
logotip4ik Jan 20, 2024
f1598b1
chore: stub page wrapper file
logotip4ik Jan 20, 2024
a2fcafd
test: bump a bit delay
logotip4ik Jan 20, 2024
d462b0c
fix: actually add page wrapper stub file only for webpack
logotip4ik Jan 20, 2024
ef82a21
Merge remote-tracking branch 'origin/main' into feat/client-only-page
danielroe Jan 29, 2024
bcb046f
test: rename index client only page to index.client
logotip4ik Feb 1, 2024
7aff470
test: remove not needed `definePageMeta` call
logotip4ik Feb 1, 2024
994831b
refactor: add mode to page meta type
logotip4ik Feb 1, 2024
a273877
refactor: strip `client` extension from page segment and apply client…
logotip4ik Feb 1, 2024
6ae06e1
refactor: pass nuxt pages to page wrapper plugin to check for client …
logotip4ik Feb 1, 2024
5fc8063
refactor: use route meta to check if client mode is enabled
logotip4ik Feb 1, 2024
dd52fec
refactor: more consistent plugin naming
logotip4ik Feb 1, 2024
b9b648e
Merge branch 'main' of github.com:logotip4ik/nuxt into feat/client-on…
logotip4ik Feb 1, 2024
ad9797e
refactor: try to reexport page wrapped in createClienOnly
logotip4ik Feb 3, 2024
70409eb
chore: Remove not used plugin
logotip4ik Feb 3, 2024
ad55085
chore: less diff
logotip4ik Feb 3, 2024
c610822
chore: even less diff
logotip4ik Feb 3, 2024
4fadacf
chore: allow all page modes beeing the query value
logotip4ik Feb 3, 2024
d1417f5
Merge branch 'main' into feat/client-only-page
logotip4ik Feb 21, 2024
e831dca
Merge branch 'main' of github.com:logotip4ik/nuxt into feat/client-on…
logotip4ik Mar 1, 2024
f778228
fix: finally it works
logotip4ik Mar 1, 2024
087e038
chore: remove not used plugin
logotip4ik Mar 1, 2024
a3e7ef2
chore: cleanup type
logotip4ik Mar 1, 2024
3834257
chore: cleanup imports
logotip4ik Mar 1, 2024
68feb8b
chore: better description wording
logotip4ik Mar 1, 2024
3cefe8f
chore: run lint
logotip4ik Mar 1, 2024
5738bfe
chore: add no side effects to createClientPage
logotip4ik Mar 1, 2024
8e6ecd8
docs: add client only section in pages
logotip4ik Mar 1, 2024
da85070
docs: cleanup meta section
logotip4ik Mar 1, 2024
899ff0f
fix: allow passing down page props
logotip4ik Mar 2, 2024
6d240ce
chore: revert server replacement control flow changes
logotip4ik Mar 2, 2024
6f2620a
chore: use else-if to prevent double check
logotip4ik Mar 2, 2024
1a28d8d
Merge remote-tracking branch 'origin/main' into feat/client-only-page
danielroe Mar 6, 2024
86de839
style: lint
danielroe Mar 6, 2024
66fa33f
docs: add additional line
danielroe Mar 6, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/2.guide/2.directory-structure/1.pages.md
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,10 @@ function navigate(){
</script>
```

## Client-Only Pages

You can define a page as [client only](/docs/guide/directory-structure/components#client-components) by giving it a `.client.vue` suffix. None of the content of this page will be rendered on the server.

## Server-Only Pages

You can define a page as [server only](/docs/guide/directory-structure/components#server-components) by giving it a `.server.vue` suffix. While you will be able to navigate to the page using client-side navigation, controlled by `vue-router`, it will be rendered with a server component automatically, meaning the code required to render the page will not be in your client-side bundle.
Expand Down
19 changes: 19 additions & 0 deletions packages/nuxt/src/components/runtime/client-component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { defineAsyncComponent, defineComponent, h } from 'vue'
import type { AsyncComponentLoader } from 'vue'
import { default as ClientOnly } from '#app/components/client-only'

/*@__NO_SIDE_EFFECTS__*/
export const createClientPage = (loader: AsyncComponentLoader) => {
const page = defineAsyncComponent(loader)

return defineComponent({
inheritAttrs: false,
setup (_, { attrs }) {
return () => h('div', [
h(ClientOnly, undefined, {
default: () => h(page, attrs)
})
])
}
})
}
21 changes: 17 additions & 4 deletions packages/nuxt/src/pages/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,17 +84,21 @@
name: '',
path: '',
file: file.absolutePath,
children: []
children: [],
}

// Array where routes should be added, useful when adding child routes
let parent = routes

if (segments[segments.length - 1].endsWith('.server')) {
segments[segments.length - 1] = segments[segments.length - 1].replace('.server', '')
const lastSegment = segments[segments.length - 1]
if (lastSegment.endsWith('.server')) {
segments[segments.length - 1] = lastSegment.replace('.server', '')
if (options.shouldUseServerComponents) {
route.mode = 'server'
}
} else if (lastSegment.endsWith('.client')) {
segments[segments.length - 1] = lastSegment.replace('.client', '')
route.mode = 'client'
}

for (let i = 0; i < segments.length; i++) {
Expand Down Expand Up @@ -193,7 +197,7 @@
try {
extractedMeta[key] = JSON.parse(runInNewContext(`JSON.stringify(${valueString})`, {}))
} catch {
console.debug(`[nuxt] Skipping extraction of \`${key}\` metadata as it is not JSON-serializable (reading \`${absolutePath}\`).`)

Check warning on line 200 in packages/nuxt/src/pages/utils.ts

View workflow job for this annotation

GitHub Actions / code

Unexpected console statement
dynamicProperties.add(key)
continue
}
Expand All @@ -206,7 +210,7 @@
continue
}
if (element.type !== 'Literal' || typeof element.value !== 'string') {
console.debug(`[nuxt] Skipping extraction of \`${key}\` metadata as it is not an array of string literals (reading \`${absolutePath}\`).`)

Check warning on line 213 in packages/nuxt/src/pages/utils.ts

View workflow job for this annotation

GitHub Actions / code

Unexpected console statement
dynamicProperties.add(key)
continue
}
Expand All @@ -217,7 +221,7 @@
}

if (property.value.type !== 'Literal' || typeof property.value.value !== 'string') {
console.debug(`[nuxt] Skipping extraction of \`${key}\` metadata as it is not a string literal or array of string literals (reading \`${absolutePath}\`).`)

Check warning on line 224 in packages/nuxt/src/pages/utils.ts

View workflow job for this annotation

GitHub Actions / code

Unexpected console statement
dynamicProperties.add(key)
continue
}
Expand Down Expand Up @@ -451,7 +455,9 @@
redirect: `${metaImportName}?.redirect`,
component: page.mode === 'server'
? `() => createIslandPage(${route.name})`
: genDynamicImport(file, { interopDefault: true })
: page.mode === 'client'
? `() => createClientPage(${genDynamicImport(file, { interopDefault: true })})`
: genDynamicImport(file, { interopDefault: true })
}

if (page.mode === 'server') {
Expand All @@ -461,6 +467,13 @@
_createIslandPage ||= await import(${JSON.stringify(resolve(distDir, 'components/runtime/server-component'))}).then(r => r.createIslandPage)
return _createIslandPage(name)
};`)
} else if (page.mode === 'client') {
metaImports.add(`
let _createClientPage
async function createClientPage(loader) {
_createClientPage ||= await import(${JSON.stringify(resolve(distDir, 'components/runtime/client-component'))}).then(r => r.createClientPage)
return _createClientPage(loader);
}`)
}

if (route.children != null) {
Expand Down
6 changes: 4 additions & 2 deletions packages/schema/src/types/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export type TSReference = { types: string } | { path: string }
export type WatchEvent = 'add' | 'addDir' | 'change' | 'unlink' | 'unlinkDir'

// If the user does not have `@vue/language-core` installed, VueCompilerOptions will be typed as `any`,
// thus making the whole `VueTSConfig` type `any`. We only augment TSConfig if VueCompilerOptions is available.
// thus making the whole `VueTSConfig` type `any`. We only augment TSConfig if VueCompilerOptions is available.
export type VueTSConfig = 0 extends 1 & VueCompilerOptions ? TSConfig : TSConfig & { vueCompilerOptions?: VueCompilerOptions }

export type NuxtPage = {
Expand All @@ -39,9 +39,11 @@ export type NuxtPage = {
* `all` means the page will be rendered isomorphically - with JavaScript both on client and server.
*
* `server` means pages are automatically rendered with server components, so there will be no JavaScript to render the page in your client bundle.
*
* `client` means that page will render on the client-side only.
* @default 'all'
*/
mode?: 'server' | 'all'
mode?: 'client' | 'server' | 'all'
}

export type NuxtMiddleware = {
Expand Down
68 changes: 68 additions & 0 deletions test/basic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,74 @@ describe('pages', () => {
expect(response).not.toContain('don\'t look at this')
expect(response).toContain('OH NNNNNNOOOOOOOOOOO')
})

it('client only page', async () => {
const response = await fetch('/client-only').then(r => r.text())

// Should not contain rendered page on initial request
expect(response).not.toContain('"hasAccessToWindow": true')
expect(response).not.toContain('"isServer": false')

const errors: string[] = []
const { page: clientInitialPage } = await renderPage('/client-only-page')

clientInitialPage.on('console', (message) => {
const type = message.type()
if (type === 'error' || type === 'warning') {
errors.push(message.text())
}
})

// But after hydration element should appear and contain this object
expect(await clientInitialPage.locator('#state').textContent()).toMatchInlineSnapshot(`
"{
"hasAccessToWindow": true,
"isServer": false
}"
`)

expect(await clientInitialPage.locator('#server-rendered').textContent()).toMatchInlineSnapshot(`"false"`)

// Then go to non client only page
await clientInitialPage.click('a')
await new Promise((r) => setTimeout(r, 50)) // little delay to finish transition

// that page should be client rendered
expect(await clientInitialPage.locator('#server-rendered').textContent()).toMatchInlineSnapshot(`"false"`)
// and not contain any errors or warnings
expect(errors.length).toBe(0)

await clientInitialPage.close()
errors.length = 0

const { page: normalInitialPage } = await renderPage('/client-only-page/normal')

normalInitialPage.on('console', (message) => {
const type = message.type()
if (type === 'error' || type === 'warning') {
errors.push(message.text())
}
})

// Now non client only page should be sever rendered
expect(await normalInitialPage.locator('#server-rendered').textContent()).toMatchInlineSnapshot(`"true"`)

// Go to client only page
await normalInitialPage.click('a')

// and expect same object to be present
expect(await normalInitialPage.locator('#state').textContent()).toMatchInlineSnapshot(`
"{
"hasAccessToWindow": true,
"isServer": false
}"
`)

// also there should not be any errors
expect(errors.length).toBe(0)

await normalInitialPage.close()
})
})

describe('nuxt composables', () => {
Expand Down
34 changes: 34 additions & 0 deletions test/fixtures/basic/pages/client-only-page/index.client.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<script setup lang="ts">
const state = useState('test', () => {
let hasAccessToWindow = null as null | boolean

try {
hasAccessToWindow = Object.keys(window).at(0) ? true : false
} catch {
hasAccessToWindow = null
}

return {
hasAccessToWindow,
isServer: import.meta.server
}
})

const serverRendered = useState(() => import.meta.server)
</script>

<template>
<div>
<NuxtLink to="/client-only-page/normal">
normal
</NuxtLink>

<p id="state">
{{ state }}
</p>

<p id="server-rendered">
{{ serverRendered }}
</p>
</div>
</template>
15 changes: 15 additions & 0 deletions test/fixtures/basic/pages/client-only-page/normal.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<script setup lang="ts">
const renderedOnServer = useState(() => import.meta.server)
</script>

<template>
<div>
<NuxtLink to="/client-only-page">
to client only page
</NuxtLink>

<p id="server-rendered">
{{ renderedOnServer }}
</p>
</div>
</template>