@notionhq/custom-blocks 0.0.76 → 0.0.78

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 (109) hide show
  1. package/HOST.md +68 -37
  2. package/bin/src/bridge/manifest.js +3 -3
  3. package/dist/bridge/SandboxBridge.d.ts +6 -12
  4. package/dist/bridge/SandboxBridge.d.ts.map +1 -1
  5. package/dist/bridge/SandboxBridge.js +123 -71
  6. package/dist/bridge/dataSources/resolve.d.ts +3 -4
  7. package/dist/bridge/dataSources/resolve.d.ts.map +1 -1
  8. package/dist/bridge/dataSources/resolve.js +2 -12
  9. package/dist/bridge/hostState.d.ts +3 -2
  10. package/dist/bridge/hostState.d.ts.map +1 -1
  11. package/dist/bridge/loadManifest.d.ts +4 -4
  12. package/dist/bridge/loadManifest.d.ts.map +1 -1
  13. package/dist/bridge/loadManifest.js +2 -2
  14. package/dist/bridge/manifest.d.ts +3 -3
  15. package/dist/bridge/manifest.js +3 -3
  16. package/dist/bridge/messages/{ready.d.ts → connect.d.ts} +13 -11
  17. package/dist/bridge/messages/connect.d.ts.map +1 -0
  18. package/dist/bridge/messages/{ready.js → connect.js} +14 -13
  19. package/dist/bridge/messages/hostToSandbox.d.ts +24 -79
  20. package/dist/bridge/messages/hostToSandbox.d.ts.map +1 -1
  21. package/dist/bridge/messages/hostToSandbox.js +2 -3
  22. package/dist/bridge/messages/init.d.ts +35 -6
  23. package/dist/bridge/messages/init.d.ts.map +1 -1
  24. package/dist/bridge/messages/init.js +8 -4
  25. package/dist/bridge/messages/initResult.d.ts +29 -0
  26. package/dist/bridge/messages/initResult.d.ts.map +1 -0
  27. package/dist/bridge/messages/initResult.js +25 -0
  28. package/dist/bridge/messages/queryDataSource.d.ts +3 -7
  29. package/dist/bridge/messages/queryDataSource.d.ts.map +1 -1
  30. package/dist/bridge/messages/queryDataSource.js +3 -7
  31. package/dist/bridge/messages/queryDataSourceResult.d.ts +4 -240
  32. package/dist/bridge/messages/queryDataSourceResult.d.ts.map +1 -1
  33. package/dist/bridge/messages/queryDataSourceResult.js +4 -22
  34. package/dist/bridge/messages/sandboxToHost.d.ts +21 -7
  35. package/dist/bridge/messages/sandboxToHost.d.ts.map +1 -1
  36. package/dist/bridge/messages/sandboxToHost.js +4 -2
  37. package/dist/bridge/sandboxClient.d.ts +2 -4
  38. package/dist/bridge/sandboxClient.d.ts.map +1 -1
  39. package/dist/bridge/sandboxClient.js +2 -5
  40. package/dist/customBlock.d.ts +1 -1
  41. package/dist/customBlock.d.ts.map +1 -1
  42. package/dist/customBlock.js +1 -1
  43. package/dist/host/createCustomBlockHost.d.ts +6 -3
  44. package/dist/host/createCustomBlockHost.d.ts.map +1 -1
  45. package/dist/host/createCustomBlockHost.js +227 -58
  46. package/dist/host/lifecycle/initErrors.d.ts +1 -1
  47. package/dist/host/lifecycle/initErrors.d.ts.map +1 -1
  48. package/dist/host/lifecycle/initErrors.js +25 -7
  49. package/dist/host/lifecycle/types.d.ts +6 -4
  50. package/dist/host/lifecycle/types.d.ts.map +1 -1
  51. package/dist/host/messages/invalidSandboxMessage.js +0 -3
  52. package/dist/host/queries/querySubscriptions.d.ts +10 -10
  53. package/dist/host/queries/querySubscriptions.d.ts.map +1 -1
  54. package/dist/host/queries/querySubscriptions.js +35 -19
  55. package/dist/host/queries/types.d.ts +5 -4
  56. package/dist/host/queries/types.d.ts.map +1 -1
  57. package/dist/host.d.ts +5 -3
  58. package/dist/host.d.ts.map +1 -1
  59. package/dist/host.js +2 -1
  60. package/dist/index.d.ts +2 -2
  61. package/dist/index.d.ts.map +1 -1
  62. package/dist/index.js +1 -1
  63. package/dist/init.d.ts +7 -7
  64. package/dist/init.d.ts.map +1 -1
  65. package/dist/init.js +70 -50
  66. package/dist/react/index.d.ts +1 -1
  67. package/dist/react/index.d.ts.map +1 -1
  68. package/dist/react/standalonePreview.d.ts.map +1 -1
  69. package/dist/react/standalonePreview.js +2 -0
  70. package/dist/react/useCustomBlockInit.d.ts +5 -6
  71. package/dist/react/useCustomBlockInit.d.ts.map +1 -1
  72. package/dist/react/useCustomBlockInit.js +3 -19
  73. package/dist/react/useRuntimeState.d.ts +4 -3
  74. package/dist/react/useRuntimeState.d.ts.map +1 -1
  75. package/dist/react/useRuntimeState.js +3 -2
  76. package/dist/version.js +1 -1
  77. package/docs/data-sources.md +7 -5
  78. package/docs/errors.md +5 -1
  79. package/docs/lifecycle.md +19 -31
  80. package/docs/manifest.md +1 -1
  81. package/package.json +1 -1
  82. package/src/bridge/SandboxBridge.ts +151 -77
  83. package/src/bridge/dataSources/resolve.ts +3 -14
  84. package/src/bridge/hostState.ts +3 -2
  85. package/src/bridge/loadManifest.ts +6 -6
  86. package/src/bridge/manifest.ts +3 -3
  87. package/src/bridge/messages/{ready.ts → connect.ts} +15 -14
  88. package/src/bridge/messages/hostToSandbox.ts +2 -6
  89. package/src/bridge/messages/init.ts +24 -10
  90. package/src/bridge/messages/initResult.ts +37 -0
  91. package/src/bridge/messages/queryDataSource.ts +3 -7
  92. package/src/bridge/messages/queryDataSourceResult.ts +4 -24
  93. package/src/bridge/messages/sandboxToHost.ts +4 -2
  94. package/src/bridge/sandboxClient.ts +3 -8
  95. package/src/customBlock.ts +2 -2
  96. package/src/host/createCustomBlockHost.ts +269 -64
  97. package/src/host/lifecycle/initErrors.ts +25 -7
  98. package/src/host/lifecycle/types.ts +13 -4
  99. package/src/host/messages/invalidSandboxMessage.ts +0 -3
  100. package/src/host/queries/querySubscriptions.ts +58 -39
  101. package/src/host/queries/types.ts +10 -11
  102. package/src/host.ts +13 -2
  103. package/src/index.ts +3 -1
  104. package/src/init.ts +73 -60
  105. package/src/react/index.ts +0 -1
  106. package/src/react/standalonePreview.ts +2 -0
  107. package/src/react/useCustomBlockInit.ts +6 -27
  108. package/src/react/useRuntimeState.ts +4 -3
  109. package/dist/bridge/messages/ready.d.ts.map +0 -1
package/docs/lifecycle.md CHANGED
@@ -4,9 +4,15 @@ The SDK ↔ host handshake, the React wrapper that runs it, and the auto-resize
4
4
 
5
5
  ## Handshake
6
6
 
7
- `initCustomBlock()` posts `ready` to `window.parent` and awaits the host's `init` (theme, block ID/parent/page ID, current user, and `dataSources: { bindings }` keyed by semantic data-source key, which the SDK resolves against the manifest). The promise resolves with the normalized initial state, captured in `CustomBlockInitPayload` — `await` it before mounting React so hooks always see populated state.
7
+ `initCustomBlock()` completes a three-message handshake:
8
8
 
9
- - Default `timeoutMs` is 15000; rejects with `CustomBlockInitError` code `init_timeout` if the host doesn't respond.
9
+ 1. The SDK sends `connect` with a new initialization ID and some version info.
10
+ 2. The host replies with `init`, echoing that initialization ID and providing the manifest, block context, current user, and data source bindings.
11
+ 3. The SDK confirms that state is compatible and sends `initResult` with the same initialization ID.
12
+
13
+ The promise resolves only after the SDK sends `initResult.success`. Await it before mounting React so hooks always see initialized state.
14
+
15
+ - Rejects with `CustomBlockInitializationError` code `init_timeout` if the host doesn't respond.
10
16
  - In a top-level browser tab (no parent frame), rejects with `NotInIframeError` code `not_in_iframe`. `<NotionCustomBlock>` catches this, seeds placeholders, and renders `children` behind a warning banner so dev-time previews still work.
11
17
  - After init, `*Changed` events (e.g. `themeChanged`, `parentChanged`, `dataSourcesChanged`) push updates and the relevant hooks re-render.
12
18
  - `initCustomBlock` is idempotent; subsequent calls return the same promise.
@@ -44,7 +50,7 @@ function useCustomBlockInit(
44
50
 
45
51
  type UseCustomBlockInitResult =
46
52
  | { isLoaded: false; error: undefined }
47
- | { isLoaded: false; error: CustomBlockInitFailure }
53
+ | { isLoaded: false; error: CustomBlockInitializationError }
48
54
  | { isLoaded: true; error: undefined; initial: CustomBlockInitPayload };
49
55
  ```
50
56
 
@@ -69,39 +75,21 @@ function initCustomBlock(
69
75
  type InitCustomBlockOptions = { timeoutMs?: number };
70
76
  ```
71
77
 
72
- The lower-level promise API. `<NotionCustomBlock>` and `useCustomBlockInit` both call it for you. Reach for it directly only when you want to `await` init at module scope (e.g. before `ReactDOM.createRoot`). If the host replies with an init error, the promise rejects with `CustomBlockInitError`.
78
+ The lower-level promise API. `<NotionCustomBlock>` and `useCustomBlockInit` both call it for you. Reach for it directly only when you want to `await` init at module scope (e.g. before `ReactDOM.createRoot`). Expected failures reject with `CustomBlockInitializationError`.
73
79
 
74
- ### `CustomBlockInitError`
80
+ ### `CustomBlockInitializationError`
75
81
 
76
- ```ts
77
- class CustomBlockInitError extends Error {
78
- code: CustomBlockInitErrorCode;
79
- isRetryable: boolean;
80
- }
82
+ The code tells you where initialization failed:
81
83
 
82
- type CustomBlockInitErrorCode =
83
- | "no_ready"
84
- | "invalid_ready"
85
- | "manifest_unavailable"
86
- | "manifest_invalid"
87
- | "invalid_protocol_version"
88
- | "unsupported_protocol_version"
89
- | "context_unavailable"
90
- | "current_user_unavailable"
91
- | "missing_data_source_binding"
92
- | "data_source_unavailable"
93
- | "missing_property_binding"
94
- | "invalid_property_binding"
95
- | "not_in_iframe"
96
- | "init_timeout"
97
- | "unknown_error";
98
- ```
84
+ - `CustomBlockInitErrorCode`: the host rejected initialization in `init.error`.
85
+ - `CustomBlockInitResultErrorCode`: the SDK could not apply the host's state and reported why in `initResult.error`.
86
+ - `not_in_iframe` and `init_timeout`: the SDK failed before the handshake completed.
99
87
 
100
- Thrown when the host rejects initialization instead of returning the initial theme, block location, current user, and data sources. Use `error instanceof CustomBlockInitError`, `error.code`, and `error.isRetryable` to branch on host-reported setup failures. See [Error handling](./errors.md) for the shared structured error contract.
88
+ All code types are open string unions, so keep a default branch. Use `isRetryable` to decide whether offering a retry makes sense, and treat `message` as display text rather than a stable identifier. See [Error handling](./errors.md) for the shared error contract.
101
89
 
102
90
  ### `customBlock`
103
91
 
104
- Framework-neutral runtime APIs for renderers that do not use React hooks. `customBlock.getState()` returns a `CustomBlockState` snapshot that hides internal query cache details. Initialized-only getters (`getTheme`, `getBlockId`, `getParent`, `getPage`, and `getCurrentUser`) throw until `initCustomBlock()` resolves. `getManifest()` is not gated on init — it returns the declared manifest (or `null`) regardless.
92
+ Framework-neutral runtime APIs for renderers that do not use React hooks. `customBlock.getState()` returns a `CustomBlockState` snapshot that hides internal query cache details. Initialized-only getters (e.g. `getTheme`, `getBlockId`, `getParent`) throw until `initCustomBlock()` resolves.
105
93
 
106
94
  `customBlock` covers runtime state and sizing. Row querying still goes through `useDataSource`, while imperative APIs such as `pages.*` and `users.*` are already framework-neutral functions.
107
95
 
@@ -127,7 +115,7 @@ stopAutoResize();
127
115
 
128
116
  ### `NotInIframeError`
129
117
 
130
- Thrown when `initCustomBlock` is called in a top-level tab (no parent frame). It has `code: "not_in_iframe"` and `isRetryable: false`. `<NotionCustomBlock>` catches it and falls back to a standalone preview with a warning banner; direct callers can `instanceof NotInIframeError` to apply their own policy.
118
+ Thrown when `initCustomBlock` is called in a top-level tab (no parent frame). It extends `CustomBlockInitializationError`, has `code: "not_in_iframe"` and `isRetryable: false`, and can be detected specifically with `instanceof NotInIframeError`. `<NotionCustomBlock>` catches it and falls back to a standalone preview with a warning banner.
131
119
 
132
120
  ### `useCustomBlockAutoResize({ enabled? })`
133
121
 
@@ -154,7 +142,7 @@ function App() {
154
142
  Press `\` while focused in a custom block to toggle a debug overlay that replaces the block's children with a `<pre>` log of every `postMessage` sent and received over the bridge. Each line is formatted as:
155
143
 
156
144
  ```
157
- [ISO timestamp] sent/received: {"type":"ready", …}
145
+ [ISO timestamp] sent/received: {"type":"connect", …}
158
146
  ```
159
147
 
160
148
  The log is intentionally plain — no filtering or decoration — so it can be copied and pasted directly to a local coding agent for debugging.
package/docs/manifest.md CHANGED
@@ -18,7 +18,7 @@ A custom block declares its required data sources in `custom_blocks.json` at the
18
18
  }
19
19
  ```
20
20
 
21
- `initCustomBlock()` fetches the manifest and forwards it with `ready`. The `notionCustomBlock()` Vite plugin from `@notionhq/custom-blocks/vite` serves it in dev and emits it into `dist/` on build. If the file is missing, the SDK sends `ready` with `status: "success"` and `manifest: null`, which means the block has no declared data requirements. If the file is unavailable for another reason or invalid, the SDK sends `ready` with `status: "error"` and an `error` payload. Hosts should reject `init` by forwarding that full error payload unchanged.
21
+ `initCustomBlock()` fetches the manifest and forwards it with `connect`. The `notionCustomBlock()` Vite plugin from `@notionhq/custom-blocks/vite` serves it in dev and emits it into `dist/` on build. If the file is missing, the SDK omits `manifest` from `connect`. If the file is unavailable for another reason or invalid, the SDK sends `connect` with `status: "error"` and an `error` payload. The host returns its authoritative manifest in `init`. The SDK uses that manifest even when it differs from the manifest sent in `connect`.
22
22
 
23
23
  ## Vite plugin
24
24
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@notionhq/custom-blocks",
3
- "version": "0.0.76",
3
+ "version": "0.0.78",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -32,6 +32,7 @@ import {
32
32
  import { readIncomingType } from "./incomingType.js"
33
33
  import type { ManifestLoadResult } from "./loadManifest.js"
34
34
  import type { CustomBlockManifest } from "./manifest.js"
35
+ import type { ConnectMessage } from "./messages/connect.js"
35
36
  import type {
36
37
  CreatePageMessage,
37
38
  CreatePageMessageParent,
@@ -39,12 +40,19 @@ import type {
39
40
  import type { CustomBlockCreatePageErrorInfo } from "./messages/createPageResult.js"
40
41
  import type { GetUserMessage } from "./messages/getUser.js"
41
42
  import { hostToSandboxMessageSchema } from "./messages/hostToSandbox.js"
42
- import { CustomBlockInitError, type InitMessage } from "./messages/init.js"
43
+ import {
44
+ CustomBlockInitializationError,
45
+ type InitMessage,
46
+ initMessageSchema,
47
+ } from "./messages/init.js"
48
+ import type {
49
+ CustomBlockInitResultErrorInfo,
50
+ InitResultMessage,
51
+ } from "./messages/initResult.js"
43
52
  import type { InvalidHostMessage } from "./messages/invalidHostMessage.js"
44
53
  import type { ListUsersMessage } from "./messages/listUsers.js"
45
54
  import type { QueryDataSourceMessage } from "./messages/queryDataSource.js"
46
55
  import type { CustomBlockQueryDataSourceErrorInfo } from "./messages/queryDataSourceResult.js"
47
- import type { ReadyMessage } from "./messages/ready.js"
48
56
  import type { ResizeMessage } from "./messages/resize.js"
49
57
  import type { UpdatePageMessage } from "./messages/updatePage.js"
50
58
  import { PendingRequests } from "./pendingRequests.js"
@@ -55,7 +63,6 @@ import type { NotionUser } from "./users/user.js"
55
63
  * single host needs to support multiple custom blocks built with different versions of the bridge
56
64
  * protocol. Increment this number any time a breaking change is made to the bridge protocol.
57
65
  */
58
- // TODO(custom-blocks): Update when bumping bridge protocol version to 3.
59
66
  export const CUSTOM_BLOCK_BRIDGE_PROTOCOL_VERSION = 2
60
67
 
61
68
  /**
@@ -92,20 +99,22 @@ export class SandboxBridge {
92
99
  private readonly pendingUpdatePage = new PendingRequests<UpdatePageResult>(
93
100
  "custom-block-update-page",
94
101
  )
95
- private hasSentReady = false
102
+ private hasSentConnect = false
103
+ private hasReceivedInit = false
104
+ private initializationId: string | undefined
96
105
  private latestDataSourceBindings: NotionDataSourceBindings = {}
97
- private resolveInit: ((message: InitMessage) => void) | undefined
106
+ private isMockState = false
107
+ private resolveInit: (() => void) | undefined
98
108
  private rejectInit: ((reason: Error) => void) | undefined
99
- private readonly initMessage: Promise<InitMessage> = new Promise(
109
+ private readonly initMessage: Promise<void> = new Promise(
100
110
  (resolve, reject) => {
101
111
  this.resolveInit = resolve
102
112
  this.rejectInit = reject
103
113
  },
104
114
  )
105
- private manifest: CustomBlockManifest | null = null
106
115
 
107
116
  constructor() {
108
- // `ready` is sent later by `initCustomBlock` (after the manifest fetch
117
+ // `connect` is sent later by `initCustomBlock` (after the manifest fetch
109
118
  // resolves). Top-level / no-iframe rejection is handled there too, so
110
119
  // the constructor just attaches the listener.
111
120
  if (typeof window !== "undefined") {
@@ -138,7 +147,7 @@ export class SandboxBridge {
138
147
  return () => this.messageLogListeners.delete(listener)
139
148
  }
140
149
 
141
- awaitInit(signal?: AbortSignal): Promise<InitMessage> {
150
+ awaitInit(signal?: AbortSignal): Promise<void> {
142
151
  if (!signal) {
143
152
  return this.initMessage
144
153
  }
@@ -150,9 +159,9 @@ export class SandboxBridge {
150
159
  const onAbort = () => reject(signal.reason)
151
160
  signal.addEventListener("abort", onAbort, { once: true })
152
161
  this.initMessage.then(
153
- message => {
162
+ () => {
154
163
  signal.removeEventListener("abort", onAbort)
155
- resolve(message)
164
+ resolve()
156
165
  },
157
166
  err => {
158
167
  signal.removeEventListener("abort", onAbort)
@@ -162,34 +171,38 @@ export class SandboxBridge {
162
171
  })
163
172
  }
164
173
 
165
- sendReady(manifestResult: ManifestLoadResult) {
174
+ sendConnect(manifestResult: ManifestLoadResult) {
166
175
  if (typeof window === "undefined") {
167
176
  return
168
177
  }
169
- if (this.hasSentReady) {
170
- console.warn("[custom-blocks-sdk] ignoring duplicate ready message")
178
+ if (this.hasSentConnect) {
179
+ console.warn("[custom-blocks-sdk] ignoring duplicate connect message")
171
180
  return
172
181
  }
173
182
  const { manifest, error } = manifestResult
174
- this.hasSentReady = true
175
- this.manifest = manifest
176
- const readyMessage: ReadyMessage =
183
+ this.hasSentConnect = true
184
+ const initializationId = `custom-block-initialization-${this.nextRequestId}`
185
+ this.nextRequestId += 1
186
+ this.initializationId = initializationId
187
+ const connectMessage: ConnectMessage =
177
188
  error !== undefined
178
189
  ? {
179
- type: "ready",
190
+ type: "connect",
191
+ initializationId,
180
192
  status: "error",
181
193
  bridgeProtocolVersion: CUSTOM_BLOCK_BRIDGE_PROTOCOL_VERSION,
182
194
  sdkVersion: CUSTOM_BLOCKS_SDK_VERSION,
183
195
  error,
184
196
  }
185
197
  : {
186
- type: "ready",
198
+ type: "connect",
199
+ initializationId,
187
200
  status: "success",
188
201
  bridgeProtocolVersion: CUSTOM_BLOCK_BRIDGE_PROTOCOL_VERSION,
189
202
  sdkVersion: CUSTOM_BLOCKS_SDK_VERSION,
190
- manifest,
203
+ ...(manifest !== null ? { manifest } : {}),
191
204
  }
192
- this.postToHost(readyMessage)
205
+ this.postToHost(connectMessage)
193
206
  }
194
207
 
195
208
  private postToHost(message: unknown) {
@@ -254,7 +267,18 @@ export class SandboxBridge {
254
267
  // `init` is the only message valid before initialization. Handle it up
255
268
  // front so every later case can assume `status === "initialized"`.
256
269
  if (message.type === "init") {
257
- this.applyInit(message)
270
+ if (message.initializationId !== this.initializationId) {
271
+ console.warn(
272
+ `[custom-blocks-sdk] ignoring init for unknown initializationId ${message.initializationId}`,
273
+ )
274
+ return
275
+ }
276
+ if (this.hasReceivedInit) {
277
+ console.warn("[custom-blocks-sdk] ignoring duplicate init message")
278
+ return
279
+ }
280
+ this.hasReceivedInit = true
281
+ this.applyInit(message, true)
258
282
  return
259
283
  }
260
284
 
@@ -309,10 +333,12 @@ export class SandboxBridge {
309
333
  const dataSources = reuseDataSourcesForUnchangedBindings({
310
334
  previousDataSources: hostState.dataSources,
311
335
  previousBindings: this.latestDataSourceBindings,
312
- nextDataSources: resolveDataSources({
313
- manifest: this.manifest,
314
- dataSourceBindings: nextBindings,
315
- }),
336
+ nextDataSources: this.isMockState
337
+ ? resolveMockDataSources(nextBindings)
338
+ : resolveDataSources({
339
+ manifest: hostState.manifest,
340
+ dataSourceBindings: nextBindings,
341
+ }),
316
342
  nextBindings,
317
343
  })
318
344
  this.latestDataSourceBindings = nextBindings
@@ -400,32 +426,23 @@ export class SandboxBridge {
400
426
 
401
427
  case "queryDataSourceResult": {
402
428
  const queryEntry = Object.entries(hostState.dataSourceState).find(
403
- ([, state]) => state.latestSnapshotId === message.snapshotId,
429
+ ([, state]) => state.subscriptionId === message.subscriptionId,
404
430
  )
405
431
  if (queryEntry === undefined) {
406
432
  return
407
433
  }
408
434
  const [key, currentState] = queryEntry
409
- if (currentState.latestRequestId !== message.requestId) {
410
- return
411
- }
412
435
  const queryResult =
413
- "status" in message
414
- ? message.status === "error"
415
- ? {
416
- items: [],
417
- hasMore: false,
418
- error: message.error,
419
- }
420
- : {
421
- items: message.items,
422
- hasMore: message.hasMore,
423
- error: undefined,
424
- }
436
+ message.status === "error"
437
+ ? {
438
+ items: [],
439
+ hasMore: false,
440
+ error: message.error,
441
+ }
425
442
  : {
426
443
  items: message.items,
427
444
  hasMore: message.hasMore,
428
- error: message.error,
445
+ error: undefined,
429
446
  }
430
447
  this.hostState = {
431
448
  ...hostState,
@@ -436,10 +453,7 @@ export class SandboxBridge {
436
453
  isLoading: false,
437
454
  hasMore: queryResult.hasMore,
438
455
  error: queryResult.error,
439
- // Keep the request ID so later host-pushed refreshes for the
440
- // same subscription still match.
441
- latestRequestId: message.requestId,
442
- latestSnapshotId: message.snapshotId,
456
+ subscriptionId: message.subscriptionId,
443
457
  latestLimit: currentState.latestLimit,
444
458
  },
445
459
  },
@@ -463,16 +477,6 @@ export class SandboxBridge {
463
477
  return this.hostState
464
478
  }
465
479
 
466
- /**
467
- * The author-declared manifest loaded from `custom_blocks.json` and forwarded
468
- * to the host in `ready`. `null` when it failed to load/parse and the host
469
- * should reject init via `ready.status: "error"`. Static for the lifetime
470
- * of the sandbox.
471
- */
472
- getManifest(): CustomBlockManifest | null {
473
- return this.manifest
474
- }
475
-
476
480
  /**
477
481
  * Apply an `init` payload as if it had arrived from the host. Lets callers
478
482
  * seed the bridge directly (e.g. the React provider's standalone preview
@@ -480,10 +484,14 @@ export class SandboxBridge {
480
484
  * of why it's being seeded.
481
485
  */
482
486
  setMockState(message: InitMessage) {
483
- this.applyInit(message)
487
+ this.isMockState = true
488
+ this.applyInit(v.parse(initMessageSchema, message), false)
484
489
  }
485
490
 
486
- private applyInit(message: InitMessage) {
491
+ private applyInit(message: InitMessage, postResult: boolean) {
492
+ if (postResult) {
493
+ this.isMockState = false
494
+ }
487
495
  if (message.status === "error") {
488
496
  // The host couldn't construct block location for this block (most commonly the parent record
489
497
  // failed to resolve). Surface the failure through the `awaitInit` promise so callers see
@@ -495,7 +503,7 @@ export class SandboxBridge {
495
503
  `[custom-blocks-sdk] host reported init error (${message.error.code}): ${message.error.message}`,
496
504
  )
497
505
  if (this.rejectInit) {
498
- this.rejectInit(new CustomBlockInitError(message.error))
506
+ this.rejectInit(new CustomBlockInitializationError(message.error))
499
507
  this.resolveInit = undefined
500
508
  this.rejectInit = undefined
501
509
  }
@@ -505,10 +513,28 @@ export class SandboxBridge {
505
513
  const { blockId, parent, page } = message
506
514
 
507
515
  this.latestDataSourceBindings = message.dataSources.bindings
508
- const dataSources = resolveDataSources({
509
- manifest: this.manifest,
510
- dataSourceBindings: this.latestDataSourceBindings,
511
- })
516
+ const dataSources = postResult
517
+ ? resolveDataSources({
518
+ manifest: message.manifest,
519
+ dataSourceBindings: this.latestDataSourceBindings,
520
+ })
521
+ : resolveMockDataSources(this.latestDataSourceBindings)
522
+ const bindingError = getInitBindingError(message.manifest, dataSources)
523
+ if (postResult && bindingError !== undefined) {
524
+ const result: InitResultMessage = {
525
+ type: "initResult",
526
+ initializationId: message.initializationId,
527
+ status: "error",
528
+ error: bindingError,
529
+ }
530
+ this.postToHost(result)
531
+ if (this.rejectInit) {
532
+ this.rejectInit(new CustomBlockInitializationError(bindingError))
533
+ this.resolveInit = undefined
534
+ this.rejectInit = undefined
535
+ }
536
+ return
537
+ }
512
538
  this.hostState = {
513
539
  status: "initialized",
514
540
  theme: message.theme,
@@ -516,15 +542,24 @@ export class SandboxBridge {
516
542
  parent,
517
543
  page,
518
544
  currentUser: message.currentUser,
545
+ manifest: message.manifest,
519
546
  dataSources,
520
547
  dataSourceState: {},
521
548
  }
522
549
  this.notify()
550
+ if (postResult) {
551
+ const result: InitResultMessage = {
552
+ type: "initResult",
553
+ initializationId: message.initializationId,
554
+ status: "success",
555
+ }
556
+ this.postToHost(result)
557
+ }
523
558
  // Resolve the awaitInit promise once. Subsequent `init` messages
524
559
  // (the host shouldn't send these, but be tolerant) update state but
525
560
  // don't re-resolve.
526
561
  if (this.resolveInit) {
527
- this.resolveInit(message)
562
+ this.resolveInit()
528
563
  this.resolveInit = undefined
529
564
  this.rejectInit = undefined
530
565
  }
@@ -558,21 +593,18 @@ export class SandboxBridge {
558
593
  }
559
594
 
560
595
  const limit = resolveDataSourceQueryLimit(options.limit)
561
- const snapshotId = makeDataSourceSnapshotId({
596
+ const subscriptionId = makeDataSourceSubscriptionId({
562
597
  key,
563
598
  })
564
599
 
565
600
  if (
566
601
  currentState.isLoading &&
567
- currentState.latestSnapshotId === snapshotId &&
602
+ currentState.subscriptionId === subscriptionId &&
568
603
  currentState.latestLimit === limit
569
604
  ) {
570
605
  return
571
606
  }
572
607
 
573
- const requestId = `custom-block-query-${this.nextRequestId}`
574
- this.nextRequestId += 1
575
-
576
608
  this.hostState = {
577
609
  ...this.hostState,
578
610
  dataSourceState: {
@@ -581,8 +613,7 @@ export class SandboxBridge {
581
613
  ...currentState,
582
614
  isLoading: true,
583
615
  error: undefined,
584
- latestRequestId: requestId,
585
- latestSnapshotId: snapshotId,
616
+ subscriptionId,
586
617
  latestLimit: limit,
587
618
  },
588
619
  },
@@ -591,8 +622,7 @@ export class SandboxBridge {
591
622
 
592
623
  const outbound: QueryDataSourceMessage = {
593
624
  type: "queryDataSource",
594
- requestId,
595
- snapshotId,
625
+ subscriptionId,
596
626
  dataSourceId: dataSource.collectionPointer.id,
597
627
  limit,
598
628
  }
@@ -615,8 +645,7 @@ export class SandboxBridge {
615
645
  ...currentState,
616
646
  isLoading: false,
617
647
  error,
618
- latestRequestId: undefined,
619
- latestSnapshotId: undefined,
648
+ subscriptionId: undefined,
620
649
  latestLimit: undefined,
621
650
  },
622
651
  },
@@ -907,11 +936,56 @@ function resolveDataSourceQueryLimit(limit: number | undefined): number {
907
936
  return limit
908
937
  }
909
938
 
910
- function makeDataSourceSnapshotId(args: { key: string }): string {
939
+ function makeDataSourceSubscriptionId(args: { key: string }): string {
911
940
  const { key } = args
912
941
  return `data-source:${encodeURIComponent(key)}`
913
942
  }
914
943
 
944
+ function getInitBindingError(
945
+ manifest: CustomBlockManifest,
946
+ dataSources: NotionDataSource[],
947
+ ): CustomBlockInitResultErrorInfo | undefined {
948
+ for (const [dataSourceKey, manifestDataSource] of Object.entries(
949
+ manifest.dataSources,
950
+ )) {
951
+ const dataSource = dataSources.find(entry => entry.key === dataSourceKey)
952
+ if (
953
+ dataSource?.collectionPointer === undefined ||
954
+ dataSource.collectionSchema === undefined
955
+ ) {
956
+ return {
957
+ code: "invalid_init_bindings",
958
+ message: `Host did not provide a complete binding for data source "${dataSourceKey}".`,
959
+ isRetryable: false,
960
+ }
961
+ }
962
+ for (const propertyKey of Object.keys(
963
+ manifestDataSource.properties ?? {},
964
+ )) {
965
+ if (dataSource.propertyIdsByKey[propertyKey] === undefined) {
966
+ return {
967
+ code: "invalid_init_bindings",
968
+ message: `Host did not provide a valid binding for property "${dataSourceKey}.${propertyKey}".`,
969
+ isRetryable: false,
970
+ }
971
+ }
972
+ }
973
+ }
974
+ return undefined
975
+ }
976
+
977
+ function resolveMockDataSources(
978
+ bindings: NotionDataSourceBindings,
979
+ ): NotionDataSource[] {
980
+ return Object.entries(bindings).map(([key, binding]) => ({
981
+ key,
982
+ collectionPointer: binding.collectionPointer,
983
+ collectionSchema: binding.collectionSchema,
984
+ propertyIdsByKey: { ...(binding.propertyIdsByKey ?? {}) },
985
+ propertySchemasById: binding.collectionSchema?.propertiesById ?? {},
986
+ }))
987
+ }
988
+
915
989
  function formatInvalidHostReason(
916
990
  incomingType: string | undefined,
917
991
  issues: readonly v.BaseIssue<unknown>[],
@@ -5,7 +5,7 @@ import type {
5
5
  } from "./dataSource.js"
6
6
 
7
7
  type ResolveDataSourcesArgs = {
8
- manifest: CustomBlockManifest | null
8
+ manifest: CustomBlockManifest
9
9
  dataSourceBindings: NotionDataSourceBindings
10
10
  }
11
11
 
@@ -13,23 +13,12 @@ type ResolveDataSourcesArgs = {
13
13
  * Builds the public {@link NotionDataSource} list the SDK exposes to consumers.
14
14
  *
15
15
  * Combines the host-supplied bindings (collection pointers + schemas) with the
16
- * manifest's declared data-source keys. Property mappings are host-owned: the
17
- * SDK validates the supplied property id against the manifest and live schema,
18
- * but does not infer a missing mapping.
16
+ * manifest's declared data-source keys. The host owns every mapping; the SDK
17
+ * validates supplied IDs but never infers omitted bindings.
19
18
  */
20
19
  export function resolveDataSources(
21
20
  args: ResolveDataSourcesArgs,
22
21
  ): NotionDataSource[] {
23
- if (args.manifest === null) {
24
- return Object.entries(args.dataSourceBindings).map(([key, binding]) => ({
25
- key,
26
- collectionPointer: binding.collectionPointer,
27
- collectionSchema: binding.collectionSchema,
28
- propertyIdsByKey: { ...(binding.propertyIdsByKey ?? {}) },
29
- propertySchemasById: binding.collectionSchema?.propertiesById ?? {},
30
- }))
31
- }
32
-
33
22
  return Object.entries(args.manifest.dataSources).map(
34
23
  ([key, manifestDataSource]) => {
35
24
  const binding = args.dataSourceBindings[key]
@@ -8,6 +8,7 @@ import type {
8
8
  import type { NotionDataSourceValue } from "./dataSources/dataSourceValue.js"
9
9
  import type { NotionPropertySchema } from "./dataSources/propertySchema.js"
10
10
  import type { NotionBlockId } from "./ids.js"
11
+ import type { CustomBlockManifest } from "./manifest.js"
11
12
  import type { CustomBlockQueryDataSourceErrorInfo } from "./messages/queryDataSourceResult.js"
12
13
  import type { CustomBlockPage, NotionPageId } from "./pages/page.js"
13
14
  import type { NotionParent } from "./parent.js"
@@ -28,6 +29,7 @@ export type InitializedHostState = {
28
29
  parent: NotionParent
29
30
  page: CustomBlockPage
30
31
  currentUser: NotionUser
32
+ manifest: CustomBlockManifest
31
33
  dataSources: NotionDataSource[]
32
34
  dataSourceState: Record<string, DataSourceQueryState>
33
35
  }
@@ -38,8 +40,7 @@ export type DataSourceQueryState = {
38
40
  isLoading: boolean
39
41
  hasMore: boolean
40
42
  error?: CustomBlockQueryDataSourceErrorInfo
41
- latestRequestId?: string
42
- latestSnapshotId?: string
43
+ subscriptionId?: string
43
44
  latestLimit?: number
44
45
  }
45
46
 
@@ -4,13 +4,13 @@ import { type CustomBlockManifest, manifestSchema } from "./manifest.js"
4
4
 
5
5
  const MANIFEST_URL = "custom_blocks.json"
6
6
 
7
- export type CustomBlockReadyErrorCode =
7
+ export type CustomBlockConnectErrorCode =
8
8
  | "manifest_unavailable"
9
9
  | "manifest_invalid"
10
10
  | (string & {})
11
11
 
12
- export type CustomBlockReadyError =
13
- CustomBlockErrorInfo<CustomBlockReadyErrorCode>
12
+ export type CustomBlockConnectError =
13
+ CustomBlockErrorInfo<CustomBlockConnectErrorCode>
14
14
 
15
15
  export type ManifestLoadResult =
16
16
  | {
@@ -19,7 +19,7 @@ export type ManifestLoadResult =
19
19
  }
20
20
  | {
21
21
  manifest: null
22
- error: CustomBlockReadyError
22
+ error: CustomBlockConnectError
23
23
  }
24
24
 
25
25
  /**
@@ -27,7 +27,7 @@ export type ManifestLoadResult =
27
27
  * A missing manifest means the block has no declared data requirements.
28
28
  * Other failures return a structured error so the host can reject the manifest.
29
29
  * The SDK validates the manifest locally for author feedback; the host still
30
- * validates the ready message as the iframe trust boundary.
30
+ * validates the connect message as the iframe trust boundary.
31
31
  */
32
32
  export async function loadManifest(): Promise<ManifestLoadResult> {
33
33
  if (typeof fetch !== "function") {
@@ -48,7 +48,7 @@ export async function loadManifest(): Promise<ManifestLoadResult> {
48
48
  manifest: null,
49
49
  // Consider this retryable because it may have just been a network issue. Because `init`
50
50
  // errors are considered terminal, the host should "retry" by reloading the iframe and
51
- // sending another `ready` message.
51
+ // sending another `connect` message.
52
52
  error: { code: "manifest_unavailable", message, isRetryable: true },
53
53
  }
54
54
  }
@@ -3,9 +3,9 @@ import { notionPropertyTypeSchema } from "./dataSources/propertySchema.js"
3
3
 
4
4
  /**
5
5
  * User-authored manifest declaring the data sources the custom block expects.
6
- * Lives at `custom_blocks.json` in the project root and is forwarded to the host with
7
- * the bridge `ready` message so the host can pre-bind data sources, surface
8
- * configuration UI, etc. The `notionCustomBlock` Vite plugin from
6
+ * Lives at `custom_blocks.json` in the project root. The sandbox may send it in
7
+ * `connect`, and the host returns the authoritative manifest in `init`. The
8
+ * `notionCustomBlock` Vite plugin from
9
9
  * `@notionhq/custom-blocks/vite` wires the JSON file into the dev server and
10
10
  * the build output.
11
11
  */