Skip to content

Commit

Permalink
feat: Add SSR support and enhance Vue integration for Tolgee (#3367)
Browse files Browse the repository at this point in the history
This PR improves the Tolgee integration with Vue, focusing on enhancing
Server-Side Rendering (SSR) support and overall functionality. Key
changes include:

- Refactored the TolgeeProvider component to handle SSR scenarios.
- Improved the useTolgee hook to better handle reactivity and event
listeners.
- Updated the T component to respect the initial render state for SSR.
- Streamlined the VueTolgee plugin to provide better support for SSR.

These changes aim to provide a more robust integration of Tolgee with
Vue applications, especially in SSR environments. The updates should
result in improved performance and developer experience when working
with translations in Vue projects.
  • Loading branch information
EugeneBalabai authored Sep 23, 2024
1 parent 674c9b4 commit 9c298aa
Show file tree
Hide file tree
Showing 46 changed files with 1,957 additions and 864 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"develop:react-i18next": "npm run develop -- --scope=@tolgee/react-i18next-testapp",
"develop:vue-i18next": "npm run develop -- --scope=@tolgee/vue-i18next-testapp",
"develop:next-app": "npm run develop -- --scope=@tolgee/next-app-testapp",
"develop:vue-ssr": "npm run develop -- --scope=@tolgee/vue-ssr-testapp",
"build:e2e": "turbo run build:e2e --cache-dir='.turbo'",
"test:e2e": "pnpm run build:e2e && pnpm --prefix e2e run start",
"clean": "turbo run clean --cache-dir='.turbo'",
Expand Down
1 change: 1 addition & 0 deletions packages/vue/src/GlobalContextPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ let globalContext: TolgeeVueContext | undefined;
export const GlobalContextPlugin = (): TolgeePlugin => (tolgee) => {
globalContext = {
tolgee,
isInitialRender: false,
};
return tolgee;
};
Expand Down
3 changes: 2 additions & 1 deletion packages/vue/src/T.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import {
TranslateProps,
TranslationKey,
} from '@tolgee/web';
import { defineComponent, PropType } from 'vue';
import { defineComponent } from 'vue';
import type { PropType } from 'vue';
import { useTranslateInternal } from './useTranslateInternal';

export const T = defineComponent({
Expand Down
6 changes: 6 additions & 0 deletions packages/vue/src/TolgeeProvider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { render, screen, waitFor } from '@testing-library/vue';
import ProviderComponent from './mocks/ProviderComponent.vue';
import ProviderComponentSlot from './mocks/ProviderComponentSlot.vue';
import { TolgeeInstance, Tolgee } from '@tolgee/web';
import { VueTolgee } from '.';

describe('Tolgee Provider Component', function () {
let mockedTolgee: TolgeeInstance;
Expand All @@ -23,6 +24,7 @@ describe('Tolgee Provider Component', function () {
getLanguage: () => 'mocked-lang',
},
},
global: { plugins: [[VueTolgee, { tolgee: mockedTolgee }]] },
});
await waitFor(() => {
screen.getByText("It's rendered!");
Expand All @@ -33,13 +35,15 @@ describe('Tolgee Provider Component', function () {
test('runs tolgee', async () => {
render(ProviderComponent, {
props: { tolgee: mockedTolgee },
global: { plugins: [[VueTolgee, { tolgee: mockedTolgee }]] },
});
expect(mockedTolgee.run).toHaveBeenCalledTimes(1);
});

test('stops tolgee', () => {
const { unmount } = render(ProviderComponent, {
props: { tolgee: mockedTolgee },
global: { plugins: [[VueTolgee, { tolgee: mockedTolgee }]] },
});
unmount();
expect(mockedTolgee.stop).toHaveBeenCalledTimes(1);
Expand All @@ -48,6 +52,7 @@ describe('Tolgee Provider Component', function () {
test('renders fallback with slot', async () => {
render(ProviderComponentSlot, {
props: { tolgee: mockedTolgee },
global: { plugins: [[VueTolgee, { tolgee: mockedTolgee }]] },
});
await waitFor(() => {
screen.getByText('loading');
Expand All @@ -62,6 +67,7 @@ describe('Tolgee Provider Component', function () {
tolgee: { ...mockedTolgee, isLoaded: () => true },
fallback: 'loading',
},
global: { plugins: [[VueTolgee, { tolgee: mockedTolgee }]] },
});
await waitFor(async () => {
screen.getByText("It's rendered!");
Expand Down
69 changes: 59 additions & 10 deletions packages/vue/src/TolgeeProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@
import {
defineComponent,
PropType,
getCurrentInstance,
provide,
onBeforeMount,
onUnmounted,
ref,
inject,
onMounted,
computed,
} from 'vue';
import { TolgeeInstance } from '@tolgee/web';
import type { Ref, ComputedRef } from 'vue';
import { TolgeeInstance, TolgeeStaticData } from '@tolgee/web';
import { TolgeeVueContext } from './types';

export const TolgeeProvider = defineComponent({
Expand All @@ -18,28 +20,75 @@ export const TolgeeProvider = defineComponent({
fallback: {
type: [Object, String] as PropType<JSX.Element | string>,
},
staticData: {
type: Object as PropType<TolgeeStaticData>,
required: false,
},
language: {
type: String as PropType<string>,
required: false,
},
},

setup(props) {
const tolgee: TolgeeInstance | undefined =
props.tolgee || getCurrentInstance().proxy.$tolgee;
const tolgeeContext: Ref<TolgeeVueContext> = inject('tolgeeContext');

// for backward compatibility
if (props.tolgee) {
tolgeeContext.value.tolgee = props.tolgee;
}

const tolgee: ComputedRef<TolgeeInstance> = computed(
() => tolgeeContext.value.tolgee
);

if (!tolgee) {
if (!tolgee.value) {
throw new Error('Tolgee instance not provided');
}

provide('tolgeeContext', { tolgee } as TolgeeVueContext);
if (tolgeeContext.value.isInitialRender) {
if (!props.staticData || !props.language) {
throw new Error(
'TolgeeProvider: "staticData" and "language" props are required for SSR.'
);
}

tolgee.value.setEmitterActive(false);
tolgee.value.addStaticData(props.staticData);
tolgee.value.changeLanguage(props.language);
tolgee.value.setEmitterActive(true);

if (!tolgee.value.isLoaded()) {
// warning user, that static data provided are not sufficient
// for proper SSR render
const missingRecords = tolgee.value
.getRequiredRecords(props.language)
.map(({ namespace, language }) =>
namespace ? `${namespace}:${language}` : language
)
.filter((key) => !props.staticData?.[key]);

// eslint-disable-next-line no-console
console.warn(
`Tolgee: Missing records in "staticData" for proper SSR functionality: ${missingRecords.map((key) => `"${key}"`).join(', ')}`
);
}
}

onMounted(() => {
tolgeeContext.value.isInitialRender = false;
});

const isLoading = ref(!tolgee.isLoaded());
const isLoading = ref(!tolgee.value.isLoaded());

onBeforeMount(() => {
tolgee.run().finally(() => {
tolgee.value.run().finally(() => {
isLoading.value = false;
});
});

onUnmounted(() => {
tolgee.stop();
tolgee.value.stop();
});
return { isLoading };
},
Expand Down
67 changes: 47 additions & 20 deletions packages/vue/src/VueTolgee.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
import type { App } from 'vue';
import { ref } from 'vue';
import { ref, watch } from 'vue';
import {
getTranslateProps,
TolgeeInstance,
TFnType,
DefaultParamType,
TranslationKey,
} from '@tolgee/web';
import { TolgeeVueContext } from './types';

type Options = {
tolgee?: TolgeeInstance;
enableSSR?: boolean;
};

type TolgeeT = TolgeeInstance['t'];

export const VueTolgee = {
install(app: App, options?: Options) {
const tolgee = options?.tolgee;
Expand All @@ -20,27 +24,50 @@ export const VueTolgee = {
throw new Error('Tolgee instance not passed in options');
}

const createTFunc = () => {
return (...props) => {
// @ts-ignore
const params = getTranslateProps(...props);
return tolgee.t(params);
};
};

const tFunc = ref(createTFunc());
tolgee.on('update', () => {
tFunc.value = createTFunc();
});
const isSsrEnabled = Boolean(options?.enableSSR);

app.mixin({
computed: {
$t() {
return tFunc.value;
},
},
const reactiveContext = ref<TolgeeVueContext>({
tolgee: tolgee,
isInitialRender: isSsrEnabled,
});
app.config.globalProperties.$tolgee = tolgee;

app.provide('tolgeeContext', reactiveContext);

if (isSsrEnabled) {
const getOriginalTolgeeInstance = (): TolgeeInstance => ({
...reactiveContext.value.tolgee,
t: ((...args: Parameters<TolgeeT>) => {
const props = getTranslateProps(...args);
return tolgee.t({ ...props });
}) as TolgeeT,
});
const getTolgeeInstanceWithDeactivatedWrapper = (): TolgeeInstance => ({
...reactiveContext.value.tolgee,
t: ((...args: Parameters<TolgeeT>) => {
const props = getTranslateProps(...args);
return tolgee.t({ ...props, noWrap: true });
}) as TolgeeT,
});

reactiveContext.value.tolgee = getTolgeeInstanceWithDeactivatedWrapper();

watch(
() => reactiveContext.value.isInitialRender,
(isInitialRender) => {
if (!isInitialRender) {
reactiveContext.value.tolgee = getOriginalTolgeeInstance();
}
}
);
}

app.config.globalProperties.$t = ((...args: Parameters<TolgeeT>) =>
reactiveContext.value.tolgee.t(...args)) as TolgeeT;

// keep it for backward compatibility
// but it is not reactive
// not recommended to use it
app.config.globalProperties.$tolgee = reactiveContext.value.tolgee;
},
};

Expand Down
11 changes: 9 additions & 2 deletions packages/vue/src/__integration/T.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import '@testing-library/jest-dom';
import { render, screen, waitFor } from '@testing-library/vue';

import { TolgeeProvider, T, TolgeeInstance, Tolgee, DevTools } from '..';
import {
TolgeeProvider,
T,
TolgeeInstance,
Tolgee,
DevTools,
VueTolgee,
} from '..';
import { FormatIcu } from '@tolgee/format-icu';
import { mockCoreFetch } from '@tolgee/testing/fetchMock';

Expand All @@ -11,7 +18,6 @@ const API_KEY = 'dummyApiKey';
const fetch = mockCoreFetch();

const TestComponent = {
inject: ['tolgeeContext'],
components: { T },
template: `
<div>
Expand Down Expand Up @@ -68,6 +74,7 @@ describe('T component integration', () => {
tolgee,
fallback: 'Loading...',
},
global: { plugins: [[VueTolgee, { tolgee }]] },
});

await waitFor(() => {
Expand Down
3 changes: 2 additions & 1 deletion packages/vue/src/__integration/useTranslate.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
Tolgee,
DevTools,
useTranslate,
VueTolgee,
} from '..';
import { FormatIcu } from '@tolgee/format-icu';
import { mockCoreFetch } from '@tolgee/testing/fetchMock';
Expand All @@ -20,7 +21,6 @@ const fetch = mockCoreFetch();
const setTitle = jest.fn();

const TestComponent = {
inject: ['tolgeeContext'],
components: { T },
setup() {
const { t } = useTranslate();
Expand Down Expand Up @@ -76,6 +76,7 @@ describe('T component integration', () => {
tolgee,
fallback: 'Loading...',
},
global: { plugins: [[VueTolgee, { tolgee }]] },
});

await waitFor(() => {
Expand Down
9 changes: 4 additions & 5 deletions packages/vue/src/mocks/ComponentUsingProvider.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,9 @@
</div>
</template>

<script lang="ts">
import { defineComponent } from 'vue';
<script setup lang="ts">
import { inject } from 'vue';
import { TolgeeVueContext } from '@tolgee/vue';
export default defineComponent({
inject: ['tolgeeContext'],
});
const tolgeeContext = inject<TolgeeVueContext>('tolgeeContext');
</script>
1 change: 1 addition & 0 deletions packages/vue/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ import { TolgeeInstance } from '@tolgee/web';

export type TolgeeVueContext = {
tolgee: TolgeeInstance;
isInitialRender: boolean;
};
23 changes: 12 additions & 11 deletions packages/vue/src/useTolgee.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,22 @@
import { TolgeeEvent } from '@tolgee/web';
import { getCurrentInstance, inject, onUnmounted, ref } from 'vue';
import { TolgeeEvent, TolgeeInstance } from '@tolgee/web';
import { inject, computed, onUnmounted, getCurrentInstance } from 'vue';
import { TolgeeVueContext } from './types';
import type { Ref, ComputedRef } from 'vue';

export const useTolgee = (events?: TolgeeEvent[]) => {
const instance = getCurrentInstance();
const tolgeeContext = inject('tolgeeContext', {
tolgee: instance.proxy.$tolgee,
}) as TolgeeVueContext;
const tolgeeContext: Ref<TolgeeVueContext> = inject('tolgeeContext');

const tolgee = ref(tolgeeContext.tolgee);
const tolgee: ComputedRef<TolgeeInstance> = computed(
() => tolgeeContext.value.tolgee
);

const listeners = events?.map((e) =>
tolgee.value.on(e, () => {
tolgee.value = Object.freeze({ ...tolgee.value });
const listeners = events?.map((e) => {
return tolgee.value.on(e, () => {
tolgeeContext.value.tolgee = Object.freeze({ ...tolgee.value });
instance.proxy.$forceUpdate();
})
);
});
});

onUnmounted(() => {
listeners?.forEach((listener) => listener.unsubscribe());
Expand Down
Loading

0 comments on commit 9c298aa

Please sign in to comment.