@tinacms/app 0.0.0-059f480-20260324232913 → 0.0.0-05c21ca-20260715013235

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.
package/package.json CHANGED
@@ -1,12 +1,22 @@
1
1
  {
2
2
  "name": "@tinacms/app",
3
- "version": "0.0.0-059f480-20260324232913",
3
+ "version": "0.0.0-05c21ca-20260715013235",
4
4
  "main": "src/main.tsx",
5
+ "files": [
6
+ "src",
7
+ "index.html",
8
+ "favicon.svg",
9
+ "tsconfig.json",
10
+ "tsconfig.node.json"
11
+ ],
5
12
  "license": "Apache-2.0",
6
13
  "devDependencies": {
7
14
  "@types/react": "^18.3.18",
8
15
  "@types/react-dom": "^18.3.5",
9
- "typescript": "^5.7.3"
16
+ "happy-dom": "15.10.2",
17
+ "typescript": "^5.7.3",
18
+ "vite": "^5.4.14",
19
+ "vitest": "^2.1.9"
10
20
  },
11
21
  "peerDependencies": {
12
22
  "react": ">=18.3.1 <20.0.0",
@@ -23,14 +33,18 @@
23
33
  "monaco-editor": "0.31.0",
24
34
  "react": "^18.3.1",
25
35
  "react-dom": "^18.3.1",
26
- "react-router-dom": "6.3.0",
36
+ "react-router-dom": "^6.30.3",
27
37
  "typescript": "^5.7.3",
28
38
  "zod": "^3.24.2",
29
- "@tinacms/mdx": "0.0.0-059f480-20260324232913",
30
- "tinacms": "0.0.0-059f480-20260324232913"
39
+ "tinacms": "^0.0.0-05c21ca-20260715013235",
40
+ "@tinacms/mdx": "^0.0.0-05c21ca-20260715013235"
31
41
  },
32
42
  "repository": {
33
43
  "url": "https://github.com/tinacms/tinacms.git",
34
44
  "directory": "packages/@tinacms/app"
45
+ },
46
+ "scripts": {
47
+ "test": "vitest run",
48
+ "test-watch": "vitest"
35
49
  }
36
50
  }
package/src/index.css CHANGED
@@ -13,8 +13,8 @@
13
13
  fill: currentColor;
14
14
  }
15
15
 
16
- .font-libre-baskerville {
17
- font-family: "Libre Baskerville", serif;
16
+ .font-inter {
17
+ font-family: "Inter", sans-serif;
18
18
  }
19
19
 
20
20
  body {
@@ -1,24 +1,27 @@
1
1
  import React from 'react';
2
2
  import { TinaCMS } from 'tinacms';
3
3
 
4
- const ErrorModalContent = (props: { title: string; errors: string[] }) => {
5
- const { title, errors } = props;
4
+ const TROUBLESHOOTING_URL = 'https://tina.io/docs/tinacloud/troubleshooting';
5
+
6
+ const ErrorModalContent = (props: { title: string; message: string }) => {
7
+ const { title, message } = props;
6
8
  return (
7
9
  <>
8
10
  <div>{title}</div>
9
- <ul>
10
- {errors.map((error, i) => (
11
- <li key={i}>{error}</li>
12
- ))}
13
- </ul>
11
+ <p>{message}</p>
12
+ <a href={TROUBLESHOOTING_URL} target='_blank' rel='noopener noreferrer'>
13
+ Try these troubleshooting tips
14
+ </a>
14
15
  </>
15
16
  );
16
17
  };
17
18
 
18
19
  export const showErrorModal = (
19
20
  title: string,
20
- errors: string[],
21
+ message: string,
21
22
  cms: TinaCMS
22
23
  ) => {
23
- cms.alerts.error(() => <ErrorModalContent title={title} errors={errors} />);
24
+ if (cms.alerts.all.some((a: { level: string }) => a.level === 'error'))
25
+ return;
26
+ cms.alerts.error(() => <ErrorModalContent title={title} message={message} />);
24
27
  };
@@ -3,14 +3,17 @@ import * as G from 'graphql';
3
3
  export const expandQuery = ({
4
4
  schema,
5
5
  documentNode,
6
+ includeNodeMetadata,
6
7
  }: {
7
8
  schema: G.GraphQLSchema;
8
9
  documentNode: G.DocumentNode;
10
+ includeNodeMetadata?: boolean;
9
11
  }): G.DocumentNode => {
10
12
  const documentNodeWithTypenames = addTypenameToDocument(documentNode);
11
13
  return addMetaFieldsToQuery(
12
14
  documentNodeWithTypenames,
13
- new G.TypeInfo(schema)
15
+ new G.TypeInfo(schema),
16
+ includeNodeMetadata
14
17
  );
15
18
  };
16
19
 
@@ -114,7 +117,8 @@ const addMetadataField = (
114
117
 
115
118
  const addMetaFieldsToQuery = (
116
119
  documentNode: G.DocumentNode,
117
- typeInfo: G.TypeInfo
120
+ typeInfo: G.TypeInfo,
121
+ includeNodeMetadata?: boolean
118
122
  ) => {
119
123
  const addMetaFields: G.VisitFn<G.ASTNode, G.FieldNode> = (
120
124
  node: G.FieldNode
@@ -126,7 +130,11 @@ const addMetaFieldsToQuery = (
126
130
  kind: 'SelectionSet',
127
131
  selections: [],
128
132
  }),
129
- selections: [...(node.selectionSet?.selections || []), ...metaFields],
133
+ selections: [
134
+ ...(node.selectionSet?.selections || []),
135
+ ...metaFields,
136
+ ...(includeNodeMetadata ? nodeMetadataFields : []),
137
+ ],
130
138
  },
131
139
  };
132
140
  };
@@ -218,6 +226,17 @@ const metaFields: G.SelectionNode[] =
218
226
  // @ts-ignore
219
227
  node.definitions[0].selectionSet.selections;
220
228
 
229
+ const nodeMetadataFragment = G.parse(`
230
+ query Sample {
231
+ ...on Document {
232
+ _tina_metadata
233
+ _content_source
234
+ }
235
+ }`);
236
+ const nodeMetadataFields: G.SelectionNode[] =
237
+ // @ts-ignore
238
+ nodeMetadataFragment.definitions[0].selectionSet.selections;
239
+
221
240
  export const isNodeType = (type: G.GraphQLOutputType) => {
222
241
  const namedType = G.getNamedType(type);
223
242
  if (G.isInterfaceType(namedType)) {
@@ -10,7 +10,6 @@ import {
10
10
  ErrorDialog,
11
11
  Form,
12
12
  FormOptions,
13
- GlobalFormPlugin,
14
13
  NAMER,
15
14
  Template,
16
15
  TinaCMS,
@@ -24,6 +23,11 @@ import { z } from 'zod';
24
23
  import { FormifyCallback, createForm, createGlobalForm } from './build-form';
25
24
  import { showErrorModal } from './errors';
26
25
  import { expandQuery, isConnectionType, isNodeType } from './expand-query';
26
+ import {
27
+ getExpectedPreviewOrigin,
28
+ isFromTrustedPreviewOrigin,
29
+ postMessageToPreview,
30
+ } from './preview-origin';
27
31
  import type {
28
32
  Payload,
29
33
  PostMessage,
@@ -190,9 +194,23 @@ export const useGraphQLReducer = (
190
194
  ResolvedDocument[]
191
195
  >([]);
192
196
  const [operationIndex, setOperationIndex] = React.useState(0);
197
+ // Pending `user-select-form` request whose form hasn't been built yet.
198
+ // Set when the bridge's `open` is still in flight (the bridge retries
199
+ // user-select-form but stops once it sees `updateData`, so we can't
200
+ // rely on a future message landing after `forms:add` resolves).
201
+ const [pendingPrimaryId, setPendingPrimaryId] = React.useState<string | null>(
202
+ null
203
+ );
193
204
 
194
205
  const activeField = searchParams.get('active-field');
195
206
 
207
+ // Origin of the preview document we load in the iframe. Used to validate
208
+ // inbound messages and as the explicit `targetOrigin` for outbound ones.
209
+ const expectedOrigin = React.useMemo(
210
+ () => getExpectedPreviewOrigin(url),
211
+ [url]
212
+ );
213
+
196
214
  React.useEffect(() => {
197
215
  const run = async () => {
198
216
  return Promise.all(
@@ -476,11 +494,15 @@ export const useGraphQLReducer = (
476
494
  });
477
495
  }
478
496
  }
479
- iframe.current?.contentWindow?.postMessage({
480
- type: 'updateData',
481
- id: payload.id,
482
- data: result.data,
483
- });
497
+ postMessageToPreview(
498
+ iframe.current?.contentWindow,
499
+ {
500
+ type: 'updateData',
501
+ id: payload.id,
502
+ data: result.data,
503
+ },
504
+ expectedOrigin
505
+ );
484
506
  }
485
507
  cms.dispatch({
486
508
  type: 'form-lists:add',
@@ -495,16 +517,47 @@ export const useGraphQLReducer = (
495
517
  [
496
518
  resolvedDocuments.map((doc) => doc._internalSys.path).join('.'),
497
519
  activeField,
520
+ expectedOrigin,
498
521
  ]
499
522
  );
500
523
 
524
+ // The bridge sends `formId` keyed by query id (hashFromQuery output), but
525
+ // `state.forms` is keyed by document path. Match by either so callers using
526
+ // `useTina`'s `experimental___selectFormByFormId` (which usually returns a
527
+ // path) keep working too. Returns false when the form isn't built yet.
528
+ const activateFormByWireId = React.useCallback(
529
+ (wireId: string): boolean => {
530
+ const match = cms.state.forms.find(
531
+ ({ tinaForm }) =>
532
+ tinaForm.id === wireId || tinaForm.queries.includes(wireId)
533
+ );
534
+ if (!match) return false;
535
+ cms.dispatch({
536
+ type: 'forms:set-active-form-id',
537
+ value: match.tinaForm.id,
538
+ });
539
+ return true;
540
+ },
541
+ [cms]
542
+ );
543
+
501
544
  const handleMessage = React.useCallback(
502
545
  (event: MessageEvent<PostMessage>) => {
546
+ // Validate the sender before reading `event.data`: only the preview
547
+ // iframe we loaded is trusted to drive the reducer.
548
+ if (
549
+ !isFromTrustedPreviewOrigin({
550
+ event,
551
+ expectedOrigin,
552
+ peerWindow: iframe.current?.contentWindow,
553
+ })
554
+ ) {
555
+ return;
556
+ }
503
557
  if (event.data.type === 'user-select-form') {
504
- cms.dispatch({
505
- type: 'forms:set-active-form-id',
506
- value: event.data.formId,
507
- });
558
+ const incoming = event.data.formId;
559
+ // Buffer until `forms:add` resolves it if the form isn't built yet.
560
+ setPendingPrimaryId(activateFormByWireId(incoming) ? null : incoming);
508
561
  }
509
562
 
510
563
  if (event?.data?.type === 'quick-edit') {
@@ -512,15 +565,23 @@ export const useGraphQLReducer = (
512
565
  type: 'set-quick-editing-supported',
513
566
  value: event.data.value,
514
567
  });
515
- iframe.current?.contentWindow?.postMessage({
516
- type: 'quickEditEnabled',
517
- value: cms.state.sidebarDisplayState === 'open',
518
- });
568
+ postMessageToPreview(
569
+ iframe.current?.contentWindow,
570
+ {
571
+ type: 'quickEditEnabled',
572
+ value: cms.state.sidebarDisplayState === 'open',
573
+ },
574
+ expectedOrigin
575
+ );
519
576
  }
520
577
  if (event?.data?.type === 'isEditMode') {
521
- iframe?.current?.contentWindow?.postMessage({
522
- type: 'tina:editMode',
523
- });
578
+ postMessageToPreview(
579
+ iframe.current?.contentWindow,
580
+ {
581
+ type: 'tina:editMode',
582
+ },
583
+ expectedOrigin
584
+ );
524
585
  }
525
586
  if (event.data.type === 'field:selected') {
526
587
  const [queryId, eventFieldName] = event.data.fieldName.split('---');
@@ -571,7 +632,7 @@ export const useGraphQLReducer = (
571
632
  // });
572
633
  // }
573
634
  },
574
- [cms, JSON.stringify(results)]
635
+ [cms, JSON.stringify(results), expectedOrigin]
575
636
  );
576
637
 
577
638
  React.useEffect(() => {
@@ -586,17 +647,30 @@ export const useGraphQLReducer = (
586
647
  return () => {
587
648
  setPayloads([]);
588
649
  setResults([]);
650
+ setPendingPrimaryId(null);
589
651
  cms.removeAllForms();
590
652
  cms.dispatch({ type: 'form-lists:clear' });
591
653
  };
592
654
  }, [url]);
593
655
 
656
+ // Drain a buffered `user-select-form` once the matching form lands in
657
+ // `state.forms`. Watches `forms.length` rather than the array identity
658
+ // so the effect doesn't re-run on unrelated form mutations.
594
659
  React.useEffect(() => {
595
- iframe.current?.contentWindow?.postMessage({
596
- type: 'quickEditEnabled',
597
- value: cms.state.sidebarDisplayState === 'open',
598
- });
599
- }, [cms.state.sidebarDisplayState]);
660
+ if (!pendingPrimaryId) return;
661
+ if (activateFormByWireId(pendingPrimaryId)) setPendingPrimaryId(null);
662
+ }, [cms.state.forms.length, pendingPrimaryId, activateFormByWireId]);
663
+
664
+ React.useEffect(() => {
665
+ postMessageToPreview(
666
+ iframe.current?.contentWindow,
667
+ {
668
+ type: 'quickEditEnabled',
669
+ value: cms.state.sidebarDisplayState === 'open',
670
+ },
671
+ expectedOrigin
672
+ );
673
+ }, [cms.state.sidebarDisplayState, expectedOrigin]);
600
674
 
601
675
  React.useEffect(() => {
602
676
  cms.dispatch({ type: 'set-edit-mode', value: 'visual' });
@@ -613,7 +687,12 @@ export const useGraphQLReducer = (
613
687
 
614
688
  React.useEffect(() => {
615
689
  if (requestErrors.length) {
616
- showErrorModal('Unexpected error querying content', requestErrors, cms);
690
+ console.error('Unexpected error querying content:', requestErrors);
691
+ showErrorModal(
692
+ 'Unexpected Error',
693
+ 'We ran into an unexpected error while fetching your content. If after refreshing the issue persists, reach out to us on Discord.',
694
+ cms
695
+ );
617
696
  }
618
697
  }, [requestErrors]);
619
698
  };
@@ -902,6 +981,7 @@ const expandPayload = async (
902
981
  const expandedDocumentNodeForResolver = expandQuery({
903
982
  schema: schemaForResolver,
904
983
  documentNode,
984
+ includeNodeMetadata: true,
905
985
  });
906
986
  const expandedQueryForResolver = G.print(expandedDocumentNodeForResolver);
907
987
  return { ...payload, expandedQuery, expandedData, expandedQueryForResolver };
@@ -1000,10 +1080,14 @@ const buildForm = ({
1000
1080
  }
1001
1081
  }
1002
1082
  if (form) {
1083
+ // Track the payload (query) id on the form so wire-side lookups —
1084
+ // notably `user-select-form`, which the bridge sends keyed by query
1085
+ // id — can resolve to the document-path-keyed `form.id` used in
1086
+ // `state.forms`. `addQuery` is otherwise only called on the second
1087
+ // open for the same document (see the `existingForm` branch above),
1088
+ // so without this the very first open leaves `form.queries` empty.
1089
+ form.addQuery(payloadId);
1003
1090
  if (shouldRegisterForm) {
1004
- if (collection.ui?.global) {
1005
- cms.plugins.add(new GlobalFormPlugin(form));
1006
- }
1007
1091
  cms.dispatch({ type: 'forms:add', value: form });
1008
1092
  }
1009
1093
  }
@@ -0,0 +1,147 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import {
3
+ getExpectedPreviewOrigin,
4
+ isFromTrustedPreviewOrigin,
5
+ postMessageToPreview,
6
+ } from './preview-origin';
7
+
8
+ const ADMIN = 'https://admin.example';
9
+ const PREVIEW = 'https://preview.example';
10
+
11
+ // A stand-in for the iframe's content window; we only need an identity to
12
+ // compare `event.source` against.
13
+ const makePeerWindow = () =>
14
+ ({ postMessage: vi.fn() }) as unknown as Window & {
15
+ postMessage: ReturnType<typeof vi.fn>;
16
+ };
17
+
18
+ describe('getExpectedPreviewOrigin', () => {
19
+ it('resolves a relative admin URL to the admin origin', () => {
20
+ expect(getExpectedPreviewOrigin('/posts/hello', ADMIN)).toBe(ADMIN);
21
+ });
22
+
23
+ it('keeps the origin of an absolute preview URL', () => {
24
+ expect(getExpectedPreviewOrigin(`${PREVIEW}/posts/hello`, ADMIN)).toBe(
25
+ PREVIEW
26
+ );
27
+ });
28
+ });
29
+
30
+ describe('isFromTrustedPreviewOrigin', () => {
31
+ it('ignores messages from an untrusted origin', () => {
32
+ const peerWindow = makePeerWindow();
33
+ const event = {
34
+ origin: 'https://evil.example',
35
+ source: peerWindow,
36
+ data: { type: 'open' },
37
+ } as unknown as MessageEvent;
38
+
39
+ expect(
40
+ isFromTrustedPreviewOrigin({
41
+ event,
42
+ expectedOrigin: PREVIEW,
43
+ peerWindow,
44
+ })
45
+ ).toBe(false);
46
+ });
47
+
48
+ it('accepts messages from the trusted origin and peer window', () => {
49
+ const peerWindow = makePeerWindow();
50
+ const event = {
51
+ origin: PREVIEW,
52
+ source: peerWindow,
53
+ data: { type: 'open' },
54
+ } as unknown as MessageEvent;
55
+
56
+ expect(
57
+ isFromTrustedPreviewOrigin({
58
+ event,
59
+ expectedOrigin: PREVIEW,
60
+ peerWindow,
61
+ })
62
+ ).toBe(true);
63
+ });
64
+
65
+ it('ignores the trusted origin from the wrong source window', () => {
66
+ const peerWindow = makePeerWindow();
67
+ const otherFrame = makePeerWindow();
68
+ const event = {
69
+ origin: PREVIEW,
70
+ source: otherFrame,
71
+ data: { type: 'open' },
72
+ } as unknown as MessageEvent;
73
+
74
+ expect(
75
+ isFromTrustedPreviewOrigin({
76
+ event,
77
+ expectedOrigin: PREVIEW,
78
+ peerWindow,
79
+ })
80
+ ).toBe(false);
81
+ });
82
+
83
+ it('does not read event.data before validating the origin', () => {
84
+ const peerWindow = makePeerWindow();
85
+ const event = {
86
+ origin: 'https://evil.example',
87
+ source: peerWindow,
88
+ // Reading `data` would throw — proves the guard never touches the
89
+ // payload when the origin is untrusted.
90
+ get data(): unknown {
91
+ throw new Error('event.data must not be read before origin validation');
92
+ },
93
+ } as unknown as MessageEvent;
94
+
95
+ expect(() =>
96
+ isFromTrustedPreviewOrigin({
97
+ event,
98
+ expectedOrigin: PREVIEW,
99
+ peerWindow,
100
+ })
101
+ ).not.toThrow();
102
+ expect(
103
+ isFromTrustedPreviewOrigin({
104
+ event,
105
+ expectedOrigin: PREVIEW,
106
+ peerWindow,
107
+ })
108
+ ).toBe(false);
109
+ });
110
+
111
+ it('falls back to origin-only when no peer window handle is available', () => {
112
+ const event = {
113
+ origin: PREVIEW,
114
+ source: makePeerWindow(),
115
+ data: { type: 'open' },
116
+ } as unknown as MessageEvent;
117
+
118
+ expect(
119
+ isFromTrustedPreviewOrigin({
120
+ event,
121
+ expectedOrigin: PREVIEW,
122
+ peerWindow: null,
123
+ })
124
+ ).toBe(true);
125
+ });
126
+ });
127
+
128
+ describe('postMessageToPreview', () => {
129
+ it('posts with the exact expected targetOrigin, not a wildcard', () => {
130
+ const peerWindow = makePeerWindow();
131
+ const message = { type: 'updateData', id: 'q1', data: {} };
132
+
133
+ postMessageToPreview(peerWindow, message, PREVIEW);
134
+
135
+ expect(peerWindow.postMessage).toHaveBeenCalledWith(message, PREVIEW);
136
+ expect(peerWindow.postMessage).not.toHaveBeenCalledWith(
137
+ expect.anything(),
138
+ '*'
139
+ );
140
+ });
141
+
142
+ it('no-ops when there is no peer window', () => {
143
+ expect(() =>
144
+ postMessageToPreview(null, { type: 'updateData' }, PREVIEW)
145
+ ).not.toThrow();
146
+ });
147
+ });
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Utilities for securing the admin <-> preview iframe postMessage channel.
3
+ *
4
+ * The trusted peer is the preview iframe loaded by the admin. Messages are only
5
+ * trusted when they match the expected preview origin and, when available, the
6
+ * iframe's exact `contentWindow`.
7
+ */
8
+
9
+ /**
10
+ * Returns the origin expected for the preview iframe.
11
+ *
12
+ * Relative URLs resolve against `baseOrigin`; absolute URLs keep their own
13
+ * origin.
14
+ */
15
+ export const getExpectedPreviewOrigin = (
16
+ url: string,
17
+ baseOrigin: string = typeof window !== 'undefined'
18
+ ? window.location.origin
19
+ : ''
20
+ ): string => {
21
+ try {
22
+ return new URL(url, baseOrigin || undefined).origin;
23
+ } catch {
24
+ return baseOrigin;
25
+ }
26
+ };
27
+
28
+ /**
29
+ * Checks whether a MessageEvent came from the trusted preview iframe.
30
+ *
31
+ * `event.data` is intentionally not read here, so callers can run this check
32
+ * before trusting the payload.
33
+ */
34
+ export const isFromTrustedPreviewOrigin = ({
35
+ event,
36
+ expectedOrigin,
37
+ peerWindow,
38
+ }: {
39
+ event: MessageEvent;
40
+ expectedOrigin: string;
41
+ peerWindow: Window | null | undefined;
42
+ }): boolean => {
43
+ if (!expectedOrigin) return false;
44
+ if (event.origin !== expectedOrigin) return false;
45
+ if (peerWindow && event.source !== peerWindow) return false;
46
+ return true;
47
+ };
48
+
49
+ /**
50
+ * Sends a message to the preview iframe with a strict target origin.
51
+ *
52
+ * No-ops when the iframe window or origin is missing, and never uses `"*"`.
53
+ */
54
+ export const postMessageToPreview = (
55
+ peerWindow: Window | null | undefined,
56
+ message: unknown,
57
+ expectedOrigin: string
58
+ ): void => {
59
+ if (!peerWindow || !expectedOrigin) return;
60
+ peerWindow.postMessage(message, expectedOrigin);
61
+ };
package/src/lib/util.ts CHANGED
@@ -93,9 +93,10 @@ export const getDeepestMetadata = (state: Object, complexKey: string): any => {
93
93
  }
94
94
  const value = current[key];
95
95
  if (value?._tina_metadata) {
96
- // We're at a reference field, we don't want to select the
97
- // reference form, just the reference select field
98
- if (complexKey === value._tina_metadata?.prefix && metadata) {
96
+ if (value._tina_metadata.id === null) {
97
+ // Placeholder metadata from connection/list types — skip it
98
+ } else if (complexKey === value._tina_metadata?.prefix && metadata) {
99
+ // Reference field — keep the parent form's metadata
99
100
  } else {
100
101
  metadata = value._tina_metadata;
101
102
  }