diff --git a/examples/ssr/App.js b/examples/ssr/App.js index 3ac118d..867eb04 100644 --- a/examples/ssr/App.js +++ b/examples/ssr/App.js @@ -69,6 +69,7 @@ export default function createApp () { return { title: 'Boring Title', htmlAttrs: { amp: true }, + bodyAttrs: { class: 'main-app' }, meta: [ { skip: this.count < 1, @@ -116,6 +117,14 @@ export default function createApp () { users: process.server ? [] : window.users } }, + mounted() { + const { set, remove } = this.$meta().addApp('client-only') + set({ + bodyAttrs: { class: 'client-only' } + }) + + setTimeout(() => remove(), 3000) + }, methods: { loadCallback () { this.count++ @@ -140,6 +149,7 @@ export default function createApp () { const { set } = app.$meta().addApp('custom') set({ + bodyAttrs: { class: 'custom-app' }, meta: [{ charset: 'utf-8' }] }) diff --git a/examples/vue-router/app.js b/examples/vue-router/app.js index 2b5c0f5..8355d92 100644 --- a/examples/vue-router/app.js +++ b/examples/vue-router/app.js @@ -18,6 +18,9 @@ const ChildComponent = { metaInfo () { return { title: `${this.page} - ${this.date && this.date.toTimeString()}`, + bodyAttrs: { + class: 'child-component' + }, afterNavigation () { metaUpdated = 'yes' } @@ -82,6 +85,9 @@ const app = new Vue(App) const { set, remove } = app.$meta().addApp('custom') set({ + bodyAttrs: { + class: 'custom-app' + }, meta: [ { charset: 'utf=8' } ] diff --git a/scripts/rollup.config.js b/scripts/rollup.config.js index 3c00656..0d1c4ae 100644 --- a/scripts/rollup.config.js +++ b/scripts/rollup.config.js @@ -43,13 +43,15 @@ function rollupConfig({ const isBrowserBuild = !config.output || !config.output.format || config.output.format === 'umd' || config.output.file.includes('.browser.') const replaceConfig = { - exclude: 'node_modules/**', + exclude: 'node_modules/(?!is-mergeable-object)', delimiters: ['', ''], values: { // replaceConfig needs to have some values 'const polyfill = process.env.NODE_ENV === \'test\'': 'const polyfill = true', 'process.env.VERSION': `"${version}"`, - 'process.server' : isBrowserBuild ? 'false' : 'true' + 'process.server' : isBrowserBuild ? 'false' : 'true', + // remove react stuff from is-mergeable-object + '|| isReactElement(value)': '|| false' } } diff --git a/src/client/load.js b/src/client/load.js index 5f3fa45..6980fd2 100644 --- a/src/client/load.js +++ b/src/client/load.js @@ -60,7 +60,11 @@ export function addListeners () { } export function applyCallbacks (matchElement) { - callbacks.forEach(([query, callback]) => { + callbacks.forEach((args) => { + // do not use destructuring for args, it increases transpiled size + // due to var checks while we are guaranteed the structure of the cb + const query = args[0] + const callback = args[1] const selector = `${query}[onload="this.__vm_l=1"]` let elements = [] diff --git a/src/client/updateClientMetaInfo.js b/src/client/updateClientMetaInfo.js index 6376214..917af3e 100644 --- a/src/client/updateClientMetaInfo.js +++ b/src/client/updateClientMetaInfo.js @@ -56,7 +56,7 @@ export default function updateClientMetaInfo (appId, options = {}, newInfo) { if (includes(metaInfoAttributeKeys, type)) { const tagName = type.substr(0, 4) - updateAttribute(options, newInfo[type], getTag(tags, tagName)) + updateAttribute(appId, options, type, newInfo[type], getTag(tags, tagName)) continue } diff --git a/src/client/updaters/attribute.js b/src/client/updaters/attribute.js index fcf17eb..e40c29b 100644 --- a/src/client/updaters/attribute.js +++ b/src/client/updaters/attribute.js @@ -1,6 +1,9 @@ import { booleanHtmlAttributes } from '../../shared/constants' -import { toArray, includes } from '../../utils/array' -import { isArray } from '../../utils/is-type' +import { includes } from '../../utils/array' + +// keep a local map of attribute values +// instead of adding it to the html +export const attributeMap = {} /** * Updates the document's html tag attributes @@ -8,39 +11,62 @@ import { isArray } from '../../utils/is-type' * @param {Object} attrs - the new document html attributes * @param {HTMLElement} tag - the HTMLElement tag to update with new attrs */ -export default function updateAttribute ({ attribute } = {}, attrs, tag) { +export default function updateAttribute (appId, { attribute } = {}, type, attrs, tag) { const vueMetaAttrString = tag.getAttribute(attribute) - const vueMetaAttrs = vueMetaAttrString ? vueMetaAttrString.split(',') : [] - const toRemove = toArray(vueMetaAttrs) + if (vueMetaAttrString) { + attributeMap[type] = JSON.parse(decodeURI(vueMetaAttrString)) + tag.removeAttribute(attribute) + } - const keepIndexes = [] - for (const attr in attrs) { - if (attrs.hasOwnProperty(attr)) { - const value = includes(booleanHtmlAttributes, attr) - ? '' - : isArray(attrs[attr]) ? attrs[attr].join(' ') : attrs[attr] + let data = attributeMap[type] || {} - tag.setAttribute(attr, value || '') + const toUpdate = [] - if (!includes(vueMetaAttrs, attr)) { - vueMetaAttrs.push(attr) + // remove attributes from the map + // which have been removed for this appId + for (const attr in data) { + if (data[attr] && appId in data[attr]) { + toUpdate.push(attr) + + if (!attrs[attr]) { + delete data[attr][appId] } - - // filter below wont ever check -1 - keepIndexes.push(toRemove.indexOf(attr)) } } - const removedAttributesCount = toRemove - .filter((el, index) => !includes(keepIndexes, index)) - .reduce((acc, attr) => { - tag.removeAttribute(attr) - return acc + 1 - }, 0) + for (const attr in attrs) { + const attrData = data[attr] - if (vueMetaAttrs.length === removedAttributesCount) { - tag.removeAttribute(attribute) - } else { - tag.setAttribute(attribute, (vueMetaAttrs.sort()).join(',')) + if (!attrData || attrData[appId] !== attrs[attr]) { + toUpdate.push(attr) + + if (attrs[attr]) { + data[attr] = data[attr] || {} + data[attr][appId] = attrs[attr] + } else { + delete data[attr][appId] + } + } } + + for (const attr of toUpdate) { + const attrData = data[attr] + + const attrValues = [] + for (const appId in attrData) { + Array.prototype.push.apply(attrValues, [].concat(attrData[appId])) + } + + if (attrValues.length) { + const attrValue = includes(booleanHtmlAttributes, attr) && attrValues.some(Boolean) + ? '' + : attrValues.filter(Boolean).join(' ') + + tag.setAttribute(attr, attrValue) + } else { + tag.removeAttribute(attr) + } + } + + attributeMap[type] = data } diff --git a/src/server/generateServerInjector.js b/src/server/generateServerInjector.js index 2adbca8..43368ff 100644 --- a/src/server/generateServerInjector.js +++ b/src/server/generateServerInjector.js @@ -47,17 +47,30 @@ export default function generateServerInjector (options, metaInfo) { } if (metaInfoAttributeKeys.includes(type)) { - let str = attributeGenerator(options, type, serverInjector.data[type], arg) + const attributeData = {} - if (serverInjector.extraData) { - for (const appId in serverInjector.extraData) { - const data = serverInjector.extraData[appId][type] - const extraStr = attributeGenerator(options, type, data, arg) - str = `${str}${extraStr}` + const data = serverInjector.data[type] + if (data) { + for (const attr in data) { + attributeData[attr] = { + [options.ssrAppId]: data[attr] + } } } - return str + for (const appId in serverInjector.extraData) { + const data = serverInjector.extraData[appId][type] + if (data) { + for (const attr in data) { + attributeData[attr] = { + ...attributeData[attr], + [appId]: data[attr] + } + } + } + } + + return attributeGenerator(options, type, attributeData, arg) } let str = tagGenerator(options, type, serverInjector.data[type], arg) diff --git a/src/server/generators/attribute.js b/src/server/generators/attribute.js index a79049a..cebc943 100644 --- a/src/server/generators/attribute.js +++ b/src/server/generators/attribute.js @@ -1,5 +1,4 @@ import { booleanHtmlAttributes } from '../../shared/constants' -import { isUndefined, isArray } from '../../utils/is-type' /** * Generates tag attributes for use on the server. @@ -10,22 +9,26 @@ import { isUndefined, isArray } from '../../utils/is-type' */ export default function attributeGenerator ({ attribute, ssrAttribute } = {}, type, data, addSrrAttribute) { let attributeStr = '' - const watchedAttrs = [] for (const attr in data) { - if (data.hasOwnProperty(attr)) { - watchedAttrs.push(attr) + const attrData = data[attr] + const attrValues = [] - attributeStr += isUndefined(data[attr]) || booleanHtmlAttributes.includes(attr) - ? attr - : `${attr}="${isArray(data[attr]) ? data[attr].join(' ') : data[attr]}"` + for (const appId in attrData) { + attrValues.push(...[].concat(attrData[appId])) + } + + if (attrValues.length) { + attributeStr += booleanHtmlAttributes.includes(attr) && attrValues.some(Boolean) + ? `${attr}` + : `${attr}="${attrValues.join(' ')}"` attributeStr += ' ' } } if (attributeStr) { - attributeStr += `${attribute}="${(watchedAttrs.sort()).join(',')}"` + attributeStr += `${attribute}="${encodeURI(JSON.stringify(data))}"` } if (type === 'htmlAttrs' && addSrrAttribute) { diff --git a/src/shared/additional-app.js b/src/shared/additional-app.js index 19c368c..ec58971 100644 --- a/src/shared/additional-app.js +++ b/src/shared/additional-app.js @@ -1,5 +1,7 @@ import updateClientMetaInfo from '../client/updateClientMetaInfo' -import { removeElementsByAppId } from '../utils/elements' +import { updateAttribute } from '../client/updaters' +import { metaInfoAttributeKeys } from '../shared/constants' +import { getTag, removeElementsByAppId } from '../utils/elements' let appsMetaInfo @@ -24,6 +26,12 @@ export function setMetaInfo (vm, appId, options, metaInfo) { export function removeMetaInfo (vm, appId, options) { if (vm && vm.$el) { + const tags = {} + for (const type of metaInfoAttributeKeys) { + const tagName = type.substr(0, 4) + updateAttribute(appId, options, type, {}, getTag(tags, tagName)) + } + return removeElementsByAppId(options, appId) } diff --git a/src/shared/constants.js b/src/shared/constants.js index ecf4c5f..8e54002 100644 --- a/src/shared/constants.js +++ b/src/shared/constants.js @@ -57,19 +57,18 @@ export const defaultOptions = { ssrAppId } +// The metaInfo property keys which are used to disable escaping +export const disableOptionKeys = [ + '__dangerouslyDisableSanitizers', + '__dangerouslyDisableSanitizersByTagID' +] + // List of metaInfo property keys which are configuration options (and dont generate html) export const metaInfoOptionKeys = [ 'titleChunk', 'titleTemplate', 'changed', - '__dangerouslyDisableSanitizers', - '__dangerouslyDisableSanitizersByTagID' -] - -// The metaInfo property keys which are used to disable escaping -export const disableOptionKeys = [ - '__dangerouslyDisableSanitizers', - '__dangerouslyDisableSanitizersByTagID' + ...disableOptionKeys ] // List of metaInfo property keys which only generates attributes and no tags diff --git a/src/shared/escaping.js b/src/shared/escaping.js index 0d3da3b..55eb284 100644 --- a/src/shared/escaping.js +++ b/src/shared/escaping.js @@ -34,7 +34,10 @@ export function escape (info, options, escapeOptions, escapeKeys) { continue } - let [ disableKey ] = disableOptionKeys + // do not use destructuring for disableOptionKeys, it increases transpiled size + // due to var checks while we are guaranteed the structure of the cb + let disableKey = disableOptionKeys[0] + if (escapeOptions[disableKey] && includes(escapeOptions[disableKey], key)) { // this info[key] doesnt need to escaped if the option is listed in __dangerouslyDisableSanitizers escaped[key] = value @@ -81,8 +84,10 @@ export function escape (info, options, escapeOptions, escapeKeys) { } export function escapeMetaInfo (options, info, escapeSequences = []) { + // do not use destructuring for seq, it increases transpiled size + // due to var checks while we are guaranteed the structure of the cb const escapeOptions = { - doEscape: value => escapeSequences.reduce((val, [v, r]) => val.replace(v, r), value) + doEscape: value => escapeSequences.reduce((val, seq) => val.replace(seq[0], seq[1]), value) } disableOptionKeys.forEach((disableKey, index) => { diff --git a/test/unit/components.test.js b/test/unit/components.test.js index bc02344..aa49fee 100644 --- a/test/unit/components.test.js +++ b/test/unit/components.test.js @@ -1,6 +1,6 @@ import { getComponentMetaInfo } from '../../src/shared/getComponentOption' import _getMetaInfo from '../../src/shared/getMetaInfo' -import { mount, createWrapper, loadVueMetaPlugin, vmTick } from '../utils' +import { mount, createWrapper, loadVueMetaPlugin, vmTick, clearClientAttributeMap } from '../utils' import { defaultOptions } from '../../src/shared/constants' import GoodbyeWorld from '../components/goodbye-world.vue' @@ -226,6 +226,13 @@ describe('client', () => { // this component uses a computed prop to simulate a non-synchronous // metaInfo update like you would have with a Vuex mutation const Component = Vue.extend({ + metaInfo () { + return { + htmlAttrs: { + theme: this.theme + } + } + }, data () { return { hiddenTheme: 'light' @@ -239,14 +246,7 @@ describe('client', () => { beforeMount () { this.hiddenTheme = 'dark' }, - render: h => h('div'), - metaInfo () { - return { - htmlAttrs: { - theme: this.theme - } - } - } + render: h => h('div') }) const vm = new Component().$mount(el) @@ -263,6 +263,8 @@ describe('client', () => { }) test('changes during hydration initialization trigger an update', async () => { + clearClientAttributeMap() + html.setAttribute(defaultOptions.ssrAttribute, 'true') const el = document.createElement('div') diff --git a/test/unit/generators.test.js b/test/unit/generators.test.js index cc27be4..cd4d4f9 100644 --- a/test/unit/generators.test.js +++ b/test/unit/generators.test.js @@ -29,7 +29,7 @@ describe('generators', () => { const testInfo = typeTests[action] // return when no test case available - if (!testCases[action] && !testInfo.test) { + if (!testCases[action]) { return } diff --git a/test/unit/updaters.test.js b/test/unit/updaters.test.js index d6c9ac2..dc38bc4 100644 --- a/test/unit/updaters.test.js +++ b/test/unit/updaters.test.js @@ -2,6 +2,7 @@ import _updateClientMetaInfo from '../../src/client/updateClientMetaInfo' import { defaultOptions, ssrAppId, ssrAttribute } from '../../src/shared/constants' import metaInfoData from '../utils/meta-info-data' import * as load from '../../src/client/load' +import { clearClientAttributeMap } from '../utils' const updateClientMetaInfo = (type, data) => _updateClientMetaInfo(ssrAppId, defaultOptions, { [type]: data }) @@ -43,7 +44,6 @@ describe('updaters', () => { }, remove: (tags) => { // TODO: i'd expect tags.removedTags to be populated - typeTests.add.expect.forEach((expected, index) => { expect(html.outerHTML).not.toContain(expected) }) @@ -57,6 +57,8 @@ describe('updaters', () => { } describe(`${type} type tests`, () => { + beforeAll(() => clearClientAttributeMap()) + Object.keys(typeTests).forEach((action) => { const testInfo = typeTests[action] diff --git a/test/utils/index.js b/test/utils/index.js index a8bd4b2..d123d3f 100644 --- a/test/utils/index.js +++ b/test/utils/index.js @@ -1,6 +1,7 @@ import { JSDOM } from 'jsdom' import { mount, shallowMount, createWrapper, createLocalVue } from '@vue/test-utils' import { renderToString } from '@vue/server-test-utils' +import { attributeMap } from '../../src/client/updaters/attribute' import { defaultOptions } from '../../src/shared/constants' import VueMetaPlugin from '../../src' @@ -39,3 +40,11 @@ export function createDOM (html = '', options = {}) { document: dom.window.document } } + +// dirty hack to remove data from previous test +// this is ok because this code normally only runs on +// the client and not during ssr +// TODO: findout why jest.resetModules doesnt work for this +export function clearClientAttributeMap() { + Object.keys(attributeMap).forEach(key => delete attributeMap[key]) +} diff --git a/test/utils/meta-info-data.js b/test/utils/meta-info-data.js index 2732e0d..96234c9 100644 --- a/test/utils/meta-info-data.js +++ b/test/utils/meta-info-data.js @@ -1,4 +1,5 @@ import { defaultOptions } from '../../src/shared/constants' +import { attributeMap } from '../../src/client/updaters/attribute' const metaInfoData = { title: { @@ -187,43 +188,127 @@ const metaInfoData = { htmlAttrs: { add: { data: { foo: 'bar' }, - expect: [''] + expect: [''], + test (side, defaultTest) { + return () => { + if (side === 'client') { + this.expect[0] = this.expect[0].replace(/ data-vue-meta="[^"]+"/, '') + } + + defaultTest() + + if (side === 'client') { + expect(attributeMap).toEqual({ htmlAttrs: { foo: { ssr: 'bar' } } }) + } + } + } }, change: { data: { foo: 'baz' }, - expect: [''] + expect: [''], + test (side, defaultTest) { + return () => { + defaultTest() + + expect(attributeMap).toEqual({ htmlAttrs: { foo: { ssr: 'baz' } } }) + } + } }, remove: { data: {}, - expect: [''] + expect: [''], + test (side, defaultTest) { + return () => { + defaultTest() + + expect(attributeMap).toEqual({ htmlAttrs: { foo: {} } }) + } + } } }, headAttrs: { add: { data: { foo: 'bar' }, - expect: [''] + expect: [''], + test (side, defaultTest) { + return () => { + if (side === 'client') { + this.expect[0] = this.expect[0].replace(/ data-vue-meta="[^"]+"/, '') + } + + defaultTest() + + if (side === 'client') { + expect(attributeMap).toEqual({ headAttrs: { foo: { ssr: 'bar' } } }) + } + } + } }, change: { data: { foo: 'baz' }, - expect: [''] + expect: [''], + test (side, defaultTest) { + return () => { + defaultTest() + + expect(attributeMap).toEqual({ headAttrs: { foo: { ssr: 'baz' } } }) + } + } }, remove: { data: {}, - expect: [''] + expect: [''], + test (side, defaultTest) { + return () => { + defaultTest() + + expect(attributeMap).toEqual({ headAttrs: { foo: {} } }) + } + } } }, bodyAttrs: { add: { data: { foo: 'bar', fizz: ['fuzz', 'fozz'] }, - expect: [''] + expect: [''], + test (side, defaultTest) { + return () => { + if (side === 'client') { + this.expect[0] = this.expect[0].replace(/ data-vue-meta="[^"]+"/, '') + } + + defaultTest() + + if (side === 'client') { + expect(attributeMap).toEqual({ bodyAttrs: { + foo: { ssr: 'bar' }, + fizz: { ssr: ['fuzz', 'fozz'] } + }}) + } + } + } }, change: { data: { foo: 'baz' }, - expect: [''] + expect: [''], + test (side, defaultTest) { + return () => { + defaultTest() + + expect(attributeMap).toEqual({ bodyAttrs: { foo: { ssr: 'baz' }, fizz: {} } }) + } + } }, remove: { data: {}, - expect: [''] + expect: [''], + test (side, defaultTest) { + return () => { + defaultTest() + + expect(attributeMap).toEqual({ bodyAttrs: { foo: {}, fizz: {} } }) + } + } } } }