@shipfox/client-shell 0.2.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +15 -0
  3. package/dist/tsconfig.test.tsbuildinfo +1 -1
  4. package/dist/vite/evaluate-features.d.ts +0 -1
  5. package/dist/vite/evaluate-features.d.ts.map +1 -1
  6. package/dist/vite/evaluate-features.js +60 -47
  7. package/dist/vite/evaluate-features.js.map +1 -1
  8. package/dist/vite/generate.js +2 -2
  9. package/dist/vite/generate.js.map +1 -1
  10. package/dist/vite/plugin.d.ts.map +1 -1
  11. package/dist/vite/plugin.js +3 -12
  12. package/dist/vite/plugin.js.map +1 -1
  13. package/package.json +28 -49
  14. package/src/vite/evaluate-features.test.ts +58 -3
  15. package/src/vite/evaluate-features.ts +71 -50
  16. package/src/vite/generate.ts +2 -2
  17. package/src/vite/plugin.test.ts +59 -9
  18. package/src/vite/plugin.ts +3 -16
  19. package/test/external/FINDINGS.md +33 -45
  20. package/test/external/README.md +21 -23
  21. package/test/external/fixture/package.json +1 -2
  22. package/test/external/fixture/src/app.fixture.tsx +78 -46
  23. package/test/external/fixture/src/features/external-settings.tsx +19 -0
  24. package/test/external/fixture/src/features/login-override.tsx +9 -0
  25. package/test/external/fixture/src/features.collision.ts +5 -5
  26. package/test/external/fixture/src/features.ts +49 -3
  27. package/test/external/fixture/src/link-typecheck.tsx +5 -5
  28. package/test/external/fixture/src/main.tsx +83 -1
  29. package/test/external/fixture/src/provider.ts +45 -0
  30. package/test/external/fixture/tsconfig.json +1 -1
  31. package/test/external/fixture/vite.config.ts +13 -2
  32. package/test/external/fixture/vitest.config.ts +3 -1
  33. package/test/external/verify.mjs +233 -47
  34. package/test/typecheck/shipfox-app.gen.ts +6 -6
  35. package/test/typecheck/types.tsx +6 -1
  36. package/tsconfig.build.tsbuildinfo +1 -1
  37. package/test/external/fixture/src/features/override-impl.tsx +0 -12
  38. package/test/external/fixture/src/features/override.tsx +0 -20
  39. package/test/external/toy-feature/CHANGELOG.md +0 -15
  40. package/test/external/toy-feature/node_modules/.bin/shipfox-biome-check +0 -43
  41. package/test/external/toy-feature/node_modules/.bin/shipfox-biome-format +0 -43
  42. package/test/external/toy-feature/node_modules/.bin/shipfox-biome-lint +0 -43
  43. package/test/external/toy-feature/node_modules/.bin/shipfox-swc +0 -43
  44. package/test/external/toy-feature/node_modules/.bin/shipfox-tsc-check +0 -43
  45. package/test/external/toy-feature/node_modules/.bin/shipfox-tsc-emit +0 -43
  46. package/test/external/toy-feature/package.json +0 -57
  47. package/test/external/toy-feature/src/config.ts +0 -5
  48. package/test/external/toy-feature/src/index.ts +0 -64
  49. package/test/external/toy-feature/src/provider.tsx +0 -33
  50. package/test/external/toy-feature/src/routes/insights.tsx +0 -7
  51. package/test/external/toy-feature/src/routes/settings.tsx +0 -7
  52. package/test/external/toy-feature/tsconfig.build.json +0 -5
  53. package/test/external/toy-feature/tsconfig.json +0 -1
  54. package/test/not-route-impl.ts +0 -1
@@ -1,13 +1,16 @@
1
- import '@testing-library/jest-dom/vitest';
2
1
  import {getLoadedConfig} from '@shipfox/client-config';
3
- import type {ProviderProbeEntry} from '@shipfox/client-shell-fixture-feature';
4
2
  import {QueryClient} from '@tanstack/react-query';
5
3
  import {createMemoryHistory} from '@tanstack/react-router';
6
- import {act, render, screen, waitFor} from '@testing-library/react';
7
- import {createClientAppElement} from './main.js';
8
- import {router} from './shipfox-app.gen.js';
4
+ import {flushSync} from 'react-dom';
5
+ import {createRoot, type Root} from 'react-dom/client';
6
+ import {auth, ClientApp, workspaceSetup} from './main';
7
+ import {readProviderEvidence, resetProviderEvidence} from './provider';
8
+ import {router} from './shipfox-app.gen';
9
9
 
10
- test('proves the external composition contract', async () => {
10
+ let container: HTMLDivElement;
11
+ let root: Root;
12
+
13
+ beforeEach(() => {
11
14
  window.matchMedia = vi.fn((query: string) => ({
12
15
  matches: false,
13
16
  media: query,
@@ -19,54 +22,83 @@ test('proves the external composition contract', async () => {
19
22
  dispatchEvent: vi.fn(() => false),
20
23
  }));
21
24
  window.scrollTo = vi.fn();
25
+ resetProviderEvidence();
26
+ container = document.createElement('div');
27
+ document.body.append(container);
28
+ root = createRoot(container);
29
+ });
30
+
31
+ afterEach(() => {
32
+ flushSync(() => root.unmount());
33
+ container.remove();
34
+ });
35
+
36
+ function mountClient(): void {
37
+ flushSync(() => root.render(<ClientApp />));
38
+ }
39
+
40
+ function heading(name: string): Element | undefined {
41
+ return [...container.querySelectorAll('h1, h2, h3')].find(
42
+ (element) => element.textContent?.trim() === name,
43
+ );
44
+ }
45
+
46
+ test('reports a missing external config fragment', async () => {
22
47
  window.__SHIPFOX_CONFIG__ = undefined;
23
- const missingConfig = render(createClientAppElement(), {reactStrictMode: true});
24
48
 
25
- expect(await screen.findByRole('heading', {name: 'Configuration error'})).toBeVisible();
26
- expect(screen.getByText('fixtureGreeting')).toBeVisible();
27
- missingConfig.unmount();
49
+ mountClient();
50
+
51
+ await vi.waitFor(() => expect(heading('Configuration error')).toBeDefined());
52
+ expect(container.textContent).toContain('externalGreeting');
53
+ });
28
54
 
29
- window.__SHIPFOX_CONFIG__ = {FIXTURE_GREETING: 'Hello from the fixture'};
30
- let providers: readonly ProviderProbeEntry[] = [];
55
+ test('renders the explicit default-route replacement', async () => {
56
+ window.__SHIPFOX_CONFIG__ = {EXTERNAL_GREETING: 'Hello from the external fixture'};
31
57
  router.update({
32
- history: createMemoryHistory({initialEntries: ['/workspaces/workspace/insights']}),
58
+ history: createMemoryHistory({initialEntries: ['/auth/login']}),
59
+ context: {auth: undefined, queryClient: undefined},
33
60
  });
34
- render(
35
- createClientAppElement({
36
- onProviders: (entries) => {
37
- providers = entries;
38
- },
39
- }),
40
- {reactStrictMode: true},
41
- );
61
+ await router.load();
42
62
 
43
- expect(await screen.findByRole('heading', {name: 'Overridden insights'})).toBeVisible();
44
- expect(screen.queryByRole('heading', {name: 'Toy insights'})).not.toBeInTheDocument();
45
- expect(screen.getAllByRole('tab').map((tab) => tab.textContent)).toEqual([
46
- 'Insights first',
47
- 'Insights second',
48
- ]);
49
- await waitFor(() => expect(providers.map(({id}) => id)).toEqual(['toy-feature', 'app-feature']));
50
- const queryClients = providers.map(({queryClient}) => queryClient);
51
- expect(queryClients).toHaveLength(2);
52
- expect(queryClients[0]).toBeInstanceOf(QueryClient);
53
- expect(queryClients[1]).toBe(queryClients[0]);
54
- expect(getLoadedConfig<{fixtureGreeting: string}>().fixtureGreeting).toBe(
55
- 'Hello from the fixture',
56
- );
63
+ mountClient();
64
+
65
+ await vi.waitFor(() => expect(heading('External login')).toBeDefined());
66
+ expect(heading('Log in')).toBeUndefined();
67
+ });
57
68
 
58
- await act(() =>
59
- router.navigate({
60
- to: '/workspaces/$wid/settings/primary',
61
- params: {wid: 'workspace'},
69
+ test('renders the external route, provider order, navigation, settings, and config', async () => {
70
+ window.__SHIPFOX_CONFIG__ = {EXTERNAL_GREETING: 'Hello from the external fixture'};
71
+ router.update({
72
+ history: createMemoryHistory({
73
+ initialEntries: ['/workspaces/workspace/settings/external'],
62
74
  }),
63
- );
75
+ context: {auth, queryClient: new QueryClient(), workspaceSetup},
76
+ });
77
+ await router.load();
78
+ mountClient();
64
79
 
65
- expect(await screen.findByRole('heading', {name: 'Toy settings'})).toBeVisible();
80
+ await vi.waitFor(() => expect(heading('External settings')).toBeDefined());
81
+ expect(
82
+ [...container.querySelectorAll('[role="tab"]')].map((tab) => tab.textContent?.trim()),
83
+ ).toEqual(['Projects', 'External', 'Settings']);
66
84
  expect(
67
- Array.from(
68
- screen.getByRole('navigation', {name: 'Workspace settings'}).querySelectorAll('a'),
69
- (link) => link.textContent,
70
- ),
71
- ).toEqual(['Primary settings', 'Secondary settings']);
85
+ [...container.querySelectorAll('[aria-label="Workspace settings"] a')]
86
+ .map((link) => link.textContent?.trim())
87
+ .slice(0, 3),
88
+ ).toEqual(['Members', 'External', 'Runners']);
89
+ expect(container.querySelector('[aria-label="External provider order"]')?.textContent).toBe(
90
+ 'outer > inner',
91
+ );
92
+ expect(container.textContent).toContain('Hello from the external fixture');
93
+ await vi.waitFor(() => expect(readProviderEvidence().map(({id}) => id)).toEqual(['outer', 'inner']));
94
+ const providers = readProviderEvidence();
95
+ expect(providers[1]?.queryClient).toBe(providers[0]?.queryClient);
96
+ expect(providers[1]?.store).toBe(providers[0]?.store);
97
+ expect(providers.map(({externalGreeting}) => externalGreeting)).toEqual([
98
+ 'Hello from the external fixture',
99
+ 'Hello from the external fixture',
100
+ ]);
101
+ expect(getLoadedConfig<{externalGreeting: string}>().externalGreeting).toBe(
102
+ 'Hello from the external fixture',
103
+ );
72
104
  });
@@ -0,0 +1,19 @@
1
+ import {getLoadedConfig} from '@shipfox/client-config';
2
+ import {defineRoute} from '@shipfox/client-shell/runtime';
3
+ import {useProviderOrder} from '../provider';
4
+
5
+ function ExternalSettings() {
6
+ const providerOrder = useProviderOrder();
7
+ const config = getLoadedConfig<{externalGreeting: string}>();
8
+ return (
9
+ <section>
10
+ <h1>External settings</h1>
11
+ <p>{config.externalGreeting}</p>
12
+ <output aria-label="External provider order">{providerOrder.join(' > ')}</output>
13
+ </section>
14
+ );
15
+ }
16
+
17
+ const route = defineRoute({component: ExternalSettings});
18
+
19
+ export default route;
@@ -0,0 +1,9 @@
1
+ import {defineRoute} from '@shipfox/client-shell/runtime';
2
+
3
+ function ExternalLogin() {
4
+ return <h1>External login</h1>;
5
+ }
6
+
7
+ const route = defineRoute({component: ExternalLogin});
8
+
9
+ export default route;
@@ -1,15 +1,15 @@
1
+ import {defaultFeatures} from '@shipfox/client-features';
1
2
  import {defineClientFeature} from '@shipfox/client-shell';
2
- import {toyFeature} from '@shipfox/client-shell-fixture-feature';
3
3
 
4
4
  const collisionFeature = defineClientFeature({
5
5
  id: 'fixture.unapproved-collision',
6
6
  routes: [
7
7
  {
8
- path: '/workspaces/$wid/insights',
9
- parent: 'workspaceLayout',
10
- impl: './features/override-impl',
8
+ path: '/auth/login',
9
+ parent: 'root',
10
+ impl: './features/login-override',
11
11
  },
12
12
  ],
13
13
  });
14
14
 
15
- export const features = [toyFeature, collisionFeature];
15
+ export const features = [...defaultFeatures(), collisionFeature];
@@ -1,4 +1,50 @@
1
- import {toyFeature} from '@shipfox/client-shell-fixture-feature';
2
- import {overrideFeature} from './features/override';
1
+ import {defaultFeatures} from '@shipfox/client-features';
2
+ import {defineClientFeature} from '@shipfox/client-shell';
3
+ import {z} from 'zod';
4
+ import {ExternalProviderInner, ExternalProviderOuter} from './provider';
3
5
 
4
- export const features = [toyFeature, overrideFeature];
6
+ export const externalConfigShape = {
7
+ externalGreeting: z.string(),
8
+ };
9
+
10
+ export const externalFeature = defineClientFeature({
11
+ id: 'fixture.external',
12
+ routes: [
13
+ {
14
+ path: '/auth/login',
15
+ parent: 'root',
16
+ override: true,
17
+ impl: './features/login-override',
18
+ },
19
+ {
20
+ path: '/workspaces/$wid/settings/external',
21
+ parent: 'workspaceSettings',
22
+ impl: './features/external-settings',
23
+ },
24
+ ],
25
+ providers: [
26
+ {id: 'fixture-provider-outer', Component: ExternalProviderOuter},
27
+ {id: 'fixture-provider-inner', Component: ExternalProviderInner},
28
+ ],
29
+ navigation: [
30
+ {
31
+ id: 'nav.external',
32
+ scope: 'workspace',
33
+ label: 'External',
34
+ to: '/workspaces/$wid/settings/external',
35
+ order: 150,
36
+ },
37
+ ],
38
+ settingsSections: [
39
+ {
40
+ id: 'settings.external',
41
+ pathSegment: 'external',
42
+ label: 'External',
43
+ icon: 'settings3Line',
44
+ order: 150,
45
+ },
46
+ ],
47
+ configShape: externalConfigShape,
48
+ });
49
+
50
+ export const features = [...defaultFeatures(), externalFeature];
@@ -1,10 +1,10 @@
1
- import {Link, useSearch} from '@tanstack/react-router';
1
+ import {Link, useParams} from '@tanstack/react-router';
2
2
 
3
- export function InsightsLink() {
4
- const search = useSearch({from: '/workspaces/$wid/insights'});
3
+ export function ExternalSettingsLink() {
4
+ const params = useParams({from: '/workspaces/$wid/settings/external'});
5
5
  return (
6
- <Link to="/workspaces/$wid/insights" params={{wid: 'workspace'}} search={{view: search.view}}>
7
- Insights
6
+ <Link to="/workspaces/$wid/settings/external" params={{wid: params.wid}}>
7
+ External settings
8
8
  </Link>
9
9
  );
10
10
  }
@@ -1,4 +1,86 @@
1
+ import {
2
+ ConfigErrorScreen,
3
+ getWindowRuntimeConfig,
4
+ loadConfig,
5
+ setLoadedConfig,
6
+ } from '@shipfox/client-config';
7
+ import {
8
+ authStateAtom,
9
+ ChromeProvider,
10
+ type AuthState,
11
+ type AuthStateValue,
12
+ mergeConfigShapes,
13
+ type RouterContext,
14
+ } from '@shipfox/client-shell/runtime';
15
+ import {ShellProviders} from '@shipfox/client-shell/testing';
16
+ import {QueryClient} from '@tanstack/react-query';
17
+ import {Outlet, RouterProvider} from '@tanstack/react-router';
18
+ import {createStore} from 'jotai';
19
+ import {useState} from 'react';
1
20
  import {createRoot} from 'react-dom/client';
21
+ import {features} from './features';
22
+ import {router} from './shipfox-app.gen';
23
+
24
+ const authState: AuthState = {
25
+ status: 'authenticated',
26
+ workspaces: [{id: 'workspace', name: 'External workspace', membershipId: 'membership'}],
27
+ };
28
+
29
+ export const auth: AuthStateValue = {
30
+ ...authState,
31
+ isLoading: false,
32
+ isAuthenticated: true,
33
+ hasWorkspace: true,
34
+ workspaces: authState.workspaces ?? [],
35
+ };
36
+
37
+ export const workspaceSetup: NonNullable<RouterContext['workspaceSetup']> = async () => ({
38
+ hideProjectNavigation: false,
39
+ });
40
+
41
+ function FixtureProjectBreadcrumb() {
42
+ return null;
43
+ }
44
+
45
+ function FixtureProjectLayoutGuard() {
46
+ return <Outlet />;
47
+ }
48
+
49
+ export function ClientApp() {
50
+ const [queryClient] = useState(
51
+ () => new QueryClient({defaultOptions: {queries: {retry: false}}}),
52
+ );
53
+ const [store] = useState(() => {
54
+ const value = createStore();
55
+ // Linked mode has two Jotai type paths; Vite dedupes them to this store at runtime.
56
+ value.set(authStateAtom as never, authState);
57
+ return value;
58
+ });
59
+ const config = loadConfig(mergeConfigShapes(features), {
60
+ runtime: getWindowRuntimeConfig(),
61
+ build: import.meta.env,
62
+ });
63
+ if (!config.ok) return <ConfigErrorScreen errors={config.errors} />;
64
+ setLoadedConfig(config.config);
65
+
66
+ return (
67
+ <ChromeProvider
68
+ chrome={{
69
+ ProjectBreadcrumb: FixtureProjectBreadcrumb,
70
+ ProjectLayoutGuard: FixtureProjectLayoutGuard,
71
+ }}
72
+ >
73
+ <ShellProviders
74
+ features={features}
75
+ queryClient={queryClient}
76
+ store={store}
77
+ config={config.config}
78
+ >
79
+ <RouterProvider router={router} context={{auth, queryClient, workspaceSetup}} />
80
+ </ShellProviders>
81
+ </ChromeProvider>
82
+ );
83
+ }
2
84
 
3
85
  const root = document.getElementById('root');
4
- if (root) createRoot(root).render(<h1>Shipfox client runtime fixture</h1>);
86
+ if (root) createRoot(root).render(<ClientApp />);
@@ -0,0 +1,45 @@
1
+ import {getLoadedConfig} from '@shipfox/client-config';
2
+ import {type QueryClient, useQueryClient} from '@tanstack/react-query';
3
+ import {type createStore, useStore} from 'jotai';
4
+ import {createContext, createElement, type PropsWithChildren, useContext} from 'react';
5
+
6
+ type Store = ReturnType<typeof createStore>;
7
+
8
+ export interface ProviderEvidence {
9
+ id: string;
10
+ queryClient: QueryClient;
11
+ store: Store;
12
+ externalGreeting: string;
13
+ }
14
+
15
+ const ProviderOrderContext = createContext<readonly string[]>([]);
16
+ const providerEvidence = new Map<string, ProviderEvidence>();
17
+
18
+ function ExternalProvider({id, children}: PropsWithChildren<{id: string}>) {
19
+ const parentOrder = useContext(ProviderOrderContext);
20
+ const queryClient = useQueryClient();
21
+ const store = useStore();
22
+ const {externalGreeting} = getLoadedConfig<{externalGreeting: string}>();
23
+ providerEvidence.set(id, {id, queryClient, store, externalGreeting});
24
+ return createElement(ProviderOrderContext.Provider, {value: [...parentOrder, id]}, children);
25
+ }
26
+
27
+ export function ExternalProviderOuter({children}: PropsWithChildren) {
28
+ return createElement(ExternalProvider, {id: 'outer'}, children);
29
+ }
30
+
31
+ export function ExternalProviderInner({children}: PropsWithChildren) {
32
+ return createElement(ExternalProvider, {id: 'inner'}, children);
33
+ }
34
+
35
+ export function useProviderOrder(): readonly string[] {
36
+ return useContext(ProviderOrderContext);
37
+ }
38
+
39
+ export function readProviderEvidence(): readonly ProviderEvidence[] {
40
+ return [...providerEvidence.values()];
41
+ }
42
+
43
+ export function resetProviderEvidence(): void {
44
+ providerEvidence.clear();
45
+ }
@@ -11,5 +11,5 @@
11
11
  "noEmit": true,
12
12
  "types": ["node", "vite/client", "vitest/globals"]
13
13
  },
14
- "include": ["src/main.tsx", "types.ts", "vite.config.ts"]
14
+ "include": ["src/**/*.ts", "src/**/*.tsx", "types.ts"]
15
15
  }
@@ -1,10 +1,21 @@
1
+ import {shipfoxClientComposition} from '@shipfox/client-shell/vite';
1
2
  import react from '@vitejs/plugin-react';
2
3
  import {defineConfig} from 'vite';
3
4
 
4
- export default defineConfig({
5
+ const config = defineConfig({
5
6
  build: {chunkSizeWarningLimit: 4_000},
6
- plugins: [react()],
7
+ plugins: [
8
+ shipfoxClientComposition({
9
+ features:
10
+ process.env.SHIPFOX_COMPOSITION_COLLISION === '1'
11
+ ? './src/features.collision.ts'
12
+ : './src/features.ts',
13
+ }),
14
+ react(),
15
+ ],
7
16
  resolve: {
8
17
  dedupe: ['react', 'react-dom', '@tanstack/react-query', '@tanstack/react-router', 'jotai'],
9
18
  },
10
19
  });
20
+
21
+ export default config;
@@ -4,12 +4,14 @@ import {defineConfig} from 'vitest/config';
4
4
  export default defineConfig({
5
5
  plugins: [react()],
6
6
  resolve: {
7
+ conditions: ['default'],
7
8
  dedupe: ['react', 'react-dom', '@tanstack/react-query', '@tanstack/react-router', 'jotai'],
8
9
  },
10
+ ssr: {resolve: {conditions: ['default']}},
9
11
  test: {
10
12
  environment: 'jsdom',
11
13
  globals: true,
12
14
  include: ['src/app.fixture.tsx'],
13
- server: {deps: {inline: true}},
15
+ server: {deps: {inline: [/@shipfox/, /@radix-ui/, /framer-motion/]}},
14
16
  },
15
17
  });