@notionhq/custom-blocks 0.1.43 → 0.1.44

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 (39) hide show
  1. package/README.md +2 -2
  2. package/dist/bridge/SandboxBridge.d.ts +9 -2
  3. package/dist/bridge/SandboxBridge.d.ts.map +1 -1
  4. package/dist/bridge/SandboxBridge.js +23 -8
  5. package/dist/bridge/dataSources/subscribe.d.ts +3 -0
  6. package/dist/bridge/dataSources/subscribe.d.ts.map +1 -0
  7. package/dist/bridge/dataSources/subscribe.js +63 -0
  8. package/dist/bridge/notifyListener.d.ts +3 -0
  9. package/dist/bridge/notifyListener.d.ts.map +1 -0
  10. package/dist/bridge/notifyListener.js +16 -0
  11. package/dist/bridge/sandboxClient.d.ts +3 -3
  12. package/dist/bridge/sandboxClient.d.ts.map +1 -1
  13. package/dist/bridge/sandboxClient.js +3 -3
  14. package/dist/customBlock.d.ts +6 -0
  15. package/dist/customBlock.d.ts.map +1 -1
  16. package/dist/customBlock.js +6 -0
  17. package/dist/protocol/index.d.ts +1 -0
  18. package/dist/protocol/index.js +1 -0
  19. package/dist/protocol/messages/sandboxToHost.d.ts +3 -0
  20. package/dist/protocol/messages/sandboxToHost.js +2 -0
  21. package/dist/protocol/messages/unsubscribeDataSourceQuery.d.ts +10 -0
  22. package/dist/protocol/messages/unsubscribeDataSourceQuery.js +9 -0
  23. package/dist/react/useDataSource.d.ts.map +1 -1
  24. package/dist/react/useDataSource.js +26 -29
  25. package/dist/types.d.ts +15 -15
  26. package/dist/types.d.ts.map +1 -1
  27. package/dist/version.js +1 -1
  28. package/docs/data-sources.md +212 -72
  29. package/docs/lifecycle.md +1 -11
  30. package/docs/pages.md +0 -2
  31. package/docs/users.md +0 -1
  32. package/package.json +1 -1
  33. package/src/bridge/SandboxBridge.ts +41 -13
  34. package/src/bridge/dataSources/subscribe.ts +97 -0
  35. package/src/bridge/notifyListener.ts +14 -0
  36. package/src/bridge/sandboxClient.ts +7 -8
  37. package/src/customBlock.ts +7 -0
  38. package/src/react/useDataSource.ts +32 -39
  39. package/src/types.ts +18 -15
@@ -1,47 +1,74 @@
1
1
  # Data sources
2
2
 
3
- A custom block declares its **data sources** semantic keys like `people` or `tasks` in the worker's `worker.customBlock(...)` configuration.
3
+ Read the Notion rows your block needs with filters, sorts, and row limits. Your subscription keeps them current as the data changes.
4
4
 
5
- The mapping from those semantic keys to concrete Notion data sources is stored on each block instance and configured in Notion.
5
+ Use `customBlock.subscribeToDataSource(...)` with any TypeScript UI framework. For React, `useDataSource(key, options)` provides the latest snapshot and handles subscription cleanup.
6
6
 
7
- Use `ManifestDataSource`, `ManifestProperty`, and `ManifestIcon` when code needs the corresponding definition metadata types.
7
+ Each snapshot contains rows, property schemas, loading and error state, and `hasMore`. Each query result replaces the previous rows in the snapshot. Subscriptions remain independent, even when they query the same data source.
8
8
 
9
- At runtime, your code references the semantic key and the SDK handles the lookup for you. Use `useDataSource(key)` for the rows themselves (it also exposes the resolved schema), and use `useManifest()` when the UI must enumerate declared data sources, such as a dynamic key switcher. For non-React renderers, `customBlock.getManifest()` exposes the same manifest, but row querying is currently only exposed through the React `useDataSource` hook.
9
+ Queries default to 20 rows and support up to 999. See [Known limitations](#known-limitations) for pagination, filter, and sort restrictions.
10
10
 
11
- ## Pages within a data source
11
+ ## Bind a data source
12
12
 
13
- Each row returned by `useDataSource` is a `NotionDataSourcePage` `{ id, propertiesById, propertiesByKey, update, archive, unarchive }`. Read property values through either of the two views:
13
+ Declare the data your block needs under `dataSources` in `worker.customBlock(...)`. Give each declaration a key, such as `people`, and declare the properties your block uses.
14
14
 
15
- - `propertiesByKey[key]` keyed by the semantic property keys you declared in the manifest.
16
- - `propertiesById[propertyId]` — keyed by the raw Notion property ID.
15
+ When someone adds the block to Notion, they bind each declaration to a data source and map its properties. Your code queries the declared key and the SDK resolves the binding. This lets the same block work with different data sources across workspaces.
17
16
 
18
- The four built-ins (`created_time`, `last_edited_time`, `created_by`, `last_edited_by`) are always present in `propertiesById` (and `collectionSchema.propertiesById`), never in the `*ByKey` views they don't have semantic keys.
17
+ Prefer `propertiesByKey` when reading declared properties. These keys stay the same across bindings. `propertiesById` exposes values keyed by the bound data source's raw property IDs.
19
18
 
20
- ### Updating a row
19
+ Use `ManifestDataSource`, `ManifestProperty`, and `ManifestIcon` for declaration metadata. To list declared keys, use `useManifest()` in React or `customBlock.getManifest()` in TypeScript. These APIs are useful for a data source switcher.
21
20
 
22
- Each page carries its own `update` helper:
21
+ ## Subscribe to rows
22
+
23
+ ### `customBlock.subscribeToDataSource(args)`
23
24
 
24
25
  ```ts
25
- await row.update({
26
- properties: {
27
- score: { type: "number", number: 8 }, // semantic key
28
- },
29
- icon: { type: "emoji", emoji: "✅" },
30
- });
26
+ function customBlock.subscribeToDataSource(
27
+ args: SubscribeToDataSourceArgs,
28
+ ): () => void;
29
+
30
+ type SubscribeToDataSourceArgs = {
31
+ key: string;
32
+ onSnapshot: (snapshot: DataSourceSnapshot) => void;
33
+ options?: DataSourceQueryOptions;
34
+ };
35
+
36
+ type DataSourceQueryOptions = {
37
+ limit?: number;
38
+ filter?: NotionDataSourceFilter;
39
+ sorts?: NotionDataSourceSort[];
40
+ };
31
41
  ```
32
42
 
33
- Property values can be keyed by **either** semantic keys or raw IDs. The SDK resolves semantic keys to IDs before sending the request.
43
+ Each call registers the `onSnapshot` listener. When you unsubscribe that listener, other listeners remain subscribed. Queries start only after SDK initialization.
34
44
 
35
- Use `row.update(...)` whenever you already have a row in hand. For pages you don't have a row for (e.g. you only have a `pageId`), drop down to the top-level [`pages` API](./pages.md) it covers create / get / update / archive / unarchive and accepts raw property IDs only.
45
+ The listener receives the current snapshot immediately and receives later updates. Unrelated host changes do not trigger the listener. A new query result can trigger the listener even if its row values are unchanged. Each snapshot contains rows, schema, loading state, and row methods for updating, archiving, and unarchiving.
36
46
 
37
- ### Archiving and unarchiving a row
47
+ The SDK calls `onSnapshot` immediately with the current snapshot, before `subscribeToDataSource` returns.
38
48
 
39
- `row.archive()` is shorthand for calling `row.update({ is_archived: true })`.
40
- `row.unarchive()` is shorthand for calling `row.update({ is_archived: false })`.
41
- Both methods return an `UpdatePageResult`.
42
- Use `row.update` to change archive status and other fields in one request.
49
+ ```ts
50
+ import { customBlock, initCustomBlock } from "@notionhq/custom-blocks";
51
+
52
+ await initCustomBlock();
43
53
 
44
- ## API
54
+ const unsubscribe = customBlock.subscribeToDataSource({
55
+ key: "people",
56
+ onSnapshot: (snapshot) => render(snapshot),
57
+ options: {
58
+ limit: 50,
59
+ sorts: [{ key: "name", direction: "ascending" }],
60
+ },
61
+ });
62
+
63
+ // When the renderer unmounts:
64
+ unsubscribe();
65
+ ```
66
+
67
+ To change the query or request more rows, unsubscribe and create a new subscription with different options.
68
+
69
+ Both APIs accept the [query options](#query-options) below. The SDK copies options when you subscribe. Later changes to the supplied object do not affect the subscription. A change to the data source definition repeats the query with the current options.
70
+
71
+ `unsubscribe()` removes this SDK subscription and its listener, then sends `unsubscribeDataSourceQuery` to the host. The host stops future refreshes and ignores pending results for that subscription. Other subscriptions remain active. Repeated calls have no effect.
45
72
 
46
73
  ### `useDataSource(key, options?)`
47
74
 
@@ -67,80 +94,148 @@ type UseDataSourceResult = {
67
94
  };
68
95
  ```
69
96
 
70
- Reads the data source mapped to `key`. The SDK uses a default `limit` of 20 and caps it at 999. To request more rows, store the limit in component state and pass it to `useDataSource` again. Each request is bounded. The hook does not provide cursor pagination.
97
+ Reads the data source mapped to `key`. To request more rows, store the limit in component state and pass it to `useDataSource` again.
98
+
99
+ `propertyIdsByKey` maps manifest property keys to raw Notion property IDs; unbound keys map to `undefined`. `propertySchemasByKey` exposes the corresponding schemas and is likewise `undefined` for declared-but-unbound slots.
100
+
101
+ `useDataSource` uses `customBlock.subscribeToDataSource` and returns its latest snapshot. A change to the key, limit, filter, or sorts replaces the subscription.
102
+
103
+ Each consumer has its own host subscription. On unmount, the SDK removes its local query state and sends an unsubscribe message. The host stops future refreshes and ignores pending results. Other consumers remain subscribed.
104
+
105
+ Query failures follow the SDK's [error-handling contract](./errors.md).
106
+
107
+ ## Query options
108
+
109
+ Both subscription APIs accept `limit`, `filter`, and `sorts`.
71
110
 
72
- `filter` accepts one property condition or one shallow `and` group. Use `key` for a semantic property key. Use `propertyId` for a raw Notion property ID. The SDK resolves semantic keys before it sends the bridge request. The SDK reports local errors for unknown or unbound keys, property-type mismatches, unsupported filter fields, invalid operators, and invalid values.
111
+ ### Row limits
112
+
113
+ `limit` defaults to 20 and must be a positive integer. The SDK caps values above 999. Each query reads from the start of the matching rows. A larger limit replaces the previous snapshot with a larger result.
114
+
115
+ `hasMore` indicates that more matching rows exist beyond the result. It does not provide a cursor or guarantee that increasing the limit can retrieve every row.
116
+
117
+ ### Filters
118
+
119
+ Use `filter` to select matching rows. Filters do not support every property type yet. Check [Property support](#property-support) before choosing a property.
120
+
121
+ Use a single condition to match unfinished tasks:
122
+
123
+ ```ts
124
+ filter: {
125
+ key: "done",
126
+ checkbox: { equals: false },
127
+ }
128
+ ```
129
+
130
+ Use `and` to match unfinished tasks whose titles also contain "launch":
131
+
132
+ ```ts
133
+ filter: {
134
+ and: [
135
+ { key: "title", title: { contains: "launch" } },
136
+ { key: "done", checkbox: { equals: false } },
137
+ ],
138
+ }
139
+ ```
140
+
141
+ Here, `title` and `done` are declared property keys. Use `propertyId` instead of `key` when you have a raw property ID. The condition type, such as `checkbox`, must match the property's schema.
142
+
143
+ Pass a single condition directly, or combine up to 25 conditions with `and`. Nested groups and `or` are unsupported. An empty `and` group applies no conditions. Both APIs accept the same filter shape.
144
+
145
+ The SDK reports invalid keys, types, operators, and values through the snapshot's `error` field.
146
+
147
+ <details>
148
+ <summary>Filter operators and values</summary>
149
+
150
+ | Property types | Filter operators |
151
+ | --- | --- |
152
+ | `title`, `rich_text`, `url`, `email`, `phone_number` | `equals`, `does_not_equal`, `contains`, `does_not_contain`, `starts_with`, `ends_with`, `is_empty`, `is_not_empty` |
153
+ | `number` | `equals`, `does_not_equal`, `greater_than`, `less_than`, `greater_than_or_equal_to`, `less_than_or_equal_to`, `is_empty`, `is_not_empty` |
154
+ | `checkbox` | `equals`, `does_not_equal` |
155
+ | `select`, `status` | `equals`, `does_not_equal`, `is_empty`, `is_not_empty` |
156
+ | `multi_select` | `contains`, `contains_all`, `does_not_contain`, `is_empty`, `is_not_empty` |
157
+ | `date` | `equals`, `before`, `after`, `on_or_before`, `on_or_after`, `is_empty`, `is_not_empty` |
158
+
159
+ Select, multi-select, and status values can be one option name or an array of option names. For multi-select properties, `contains` matches any supplied option and `contains_all` requires every supplied option. Empty checks use `{ is_empty: true }` or `{ is_not_empty: true }`. Date comparisons accept ISO dates or ISO timestamps.
160
+
161
+ </details>
162
+
163
+ ### Sorts
164
+
165
+ Use `sorts` to order matching rows. Sorts do not support every property type yet, including some types that support filters. Check [Property support](#property-support) before choosing a property.
166
+
167
+ This example sorts tasks by due date, with the earliest first. Tasks with the same due date use their creation time, newest first:
73
168
 
74
169
  ```tsx
75
170
  const query = useDataSource("tasks", {
76
- filter: {
77
- and: [
78
- { key: "title", title: { contains: "launch" } },
79
- { key: "done", checkbox: { equals: false } },
80
- ],
81
- },
82
- sorts: [{ key: "due", direction: "ascending" }],
171
+ sorts: [
172
+ { key: "due", direction: "ascending" },
173
+ { propertyId: "created_time", direction: "descending" },
174
+ ],
83
175
  limit: 50,
84
176
  });
85
177
  ```
86
178
 
87
- Filters support these property types:
179
+ Here, `due` is a declared date property key and `created_time` is a built-in property ID. Sorts apply in array order, with the first property taking priority. Empty values sort last in both directions.
88
180
 
89
- - Text properties: `title`, `rich_text`, `url`, `email`, and `phone_number`. They support `equals`, `does_not_equal`, `contains`, `does_not_contain`, `starts_with`, `ends_with`, `is_empty`, and `is_not_empty`.
90
- - `number`: `equals`, `does_not_equal`, `greater_than`, `less_than`, `greater_than_or_equal_to`, `less_than_or_equal_to`, `is_empty`, and `is_not_empty`.
91
- - `checkbox`: `equals` and `does_not_equal`.
92
- - `select` and `status`: `equals`, `does_not_equal`, `is_empty`, and `is_not_empty`.
93
- - `multi_select`: `contains`, `contains_all`, `does_not_contain`, `is_empty`, and `is_not_empty`.
94
- - `date`: `equals`, `before`, `after`, `on_or_before`, `on_or_after`, `is_empty`, and `is_not_empty`.
181
+ You can sort by up to 10 unique properties. Both APIs accept the same sort shape, and you can combine `sorts` with `filter`.
95
182
 
96
- Select, multi-select, and status values can be one option name or an array of option names. For multi-select properties, `contains` matches any supplied option and `contains_all` requires every supplied option. Empty checks use `{ is_empty: true }` or `{ is_not_empty: true }`. Date comparisons accept ISO dates or ISO timestamps.
183
+ ## Pages within a data source
97
184
 
98
- The `and` group can contain up to 25 conditions. An empty group is valid. Nested groups and `or` groups are not supported.
185
+ Each row returned by either API is a `NotionDataSourcePage` `{ id, propertiesById, propertiesByKey, update, archive, unarchive }`. Read property values through either of the two views:
99
186
 
100
- `sorts` accepts a list of property addresses and directions. Use `key` for a
101
- semantic property key or `propertyId` for a raw Notion property ID. The SDK
102
- resolves semantic keys before it sends the bridge request. The host applies
103
- sorts in array order. The first sort has the highest priority. The list can
104
- contain up to ten unique properties. Empty values sort last in either
105
- direction. Sorts currently support title, rich text, number, checkbox, URL,
106
- email, phone number, date, created time, and last edited time properties.
107
- Other property types are rejected until their sort values are supported.
187
+ - `propertiesByKey[key]` keyed by the semantic property keys you declared in the manifest.
188
+ - `propertiesById[propertyId]` keyed by the raw Notion property ID.
108
189
 
109
- `propertyIdsByKey` maps manifest property keys to raw Notion property IDs; unbound keys map to `undefined`. `propertySchemasByKey` exposes the corresponding schemas and is likewise `undefined` for declared-but-unbound slots.
190
+ The four built-ins (`created_time`, `last_edited_time`, `created_by`, `last_edited_by`) are always present in `propertiesById` and `collectionSchema.propertiesById`. They have no semantic keys, so they do not appear in the `*ByKey` views.
110
191
 
111
- Query failures follow the SDK's [error-handling contract](./errors.md).
192
+ ### Updating a row
112
193
 
113
- ### `useManifest()`
194
+ Each page carries its own `update` helper:
114
195
 
115
196
  ```ts
116
- function useManifest(): CustomBlockManifest;
197
+ await row.update({
198
+ properties: {
199
+ score: { type: "number", number: 8 }, // semantic key
200
+ },
201
+ icon: { type: "emoji", emoji: "✅" },
202
+ });
117
203
  ```
118
204
 
119
- Returns the authoritative block manifest received from the host during initialization, including the semantic data source keys plus their declared `name`, `description`, and property declarations. Most blocks should query a declared key directly:
205
+ Property values can be keyed by **either** semantic keys or raw IDs. The SDK resolves semantic keys to IDs before sending the request.
120
206
 
121
- ```tsx
122
- const query = useDataSource("tasks");
123
- ```
207
+ Use `row.update(...)` whenever you already have a row in hand. For pages you don't have a row for (e.g. you only have a `pageId`), drop down to the top-level [`pages` API](./pages.md) — it covers create / get / update / archive / unarchive and accepts raw property IDs only.
124
208
 
125
- Use `useManifest()` when the UI must enumerate declarations, such as a switcher that supports multiple data sources.
209
+ ### Archiving and unarchiving a row
126
210
 
127
- This API throws if called before initialization. This does not include host-resolved bindings nor does it include any actual data rows.
211
+ `row.archive()` is shorthand for calling `row.update({ is_archived: true })`.
212
+ `row.unarchive()` is shorthand for calling `row.update({ is_archived: false })`.
213
+ Both methods return an `UpdatePageResult`.
214
+ Use `row.update` to change archive status and other fields in one request.
128
215
 
129
- ### `customBlock.getManifest()`
216
+ ## Read declarations
217
+
218
+ Use the manifest when your UI needs to discover which data sources and properties the block declares. For example, build a data source switcher from the declared keys or table headings from the declared property names.
219
+
220
+ Both APIs below return the declaration from `worker.customBlock(...)`, including its keys, names, descriptions, and property definitions. To query a selected data source, pass its key to either subscription API. Read resolved property IDs from the query snapshot's `propertyIdsByKey` map.
221
+
222
+ Both APIs return synchronously and throw if called before `initCustomBlock()` resolves.
223
+
224
+ ### `useManifest()`
130
225
 
131
226
  ```ts
132
- function customBlock.getManifest(): CustomBlockManifest;
227
+ function useManifest(): CustomBlockManifest;
133
228
  ```
134
229
 
135
- Framework-neutral getter for the same manifest returned by `useManifest()`. Throws if called before `initCustomBlock()` resolves. The manifest is static for the lifetime of the sandbox, so there is nothing to subscribe to.
230
+ Returns the block manifest received from the host during initialization. The manifest contains declarations only. Resolved bindings and rows are available through query snapshots.
136
231
 
137
- ```ts
138
- await initCustomBlock();
232
+ ### `customBlock.getManifest()`
139
233
 
140
- renderManifest(customBlock.getManifest());
234
+ ```ts
235
+ function customBlock.getManifest(): CustomBlockManifest;
141
236
  ```
142
237
 
143
- `customBlock` does not yet expose a non-React equivalent of `useDataSource(key)`: querying rows, tracking `isLoading` / `hasMore`, and using row-level `update` helpers still require the React hook.
238
+ Framework-neutral getter for the same manifest returned by `useManifest()`. The manifest is static for the lifetime of the sandbox, so there is nothing to subscribe to.
144
239
 
145
240
  ## Example: querying a data source
146
241
 
@@ -185,10 +280,10 @@ export function ScoreList() {
185
280
  </li>
186
281
  ))}
187
282
  </ul>
188
- {hasMore ? (
283
+ {hasMore && limit < 999 ? (
189
284
  <button
190
285
  type="button"
191
- onClick={() => setLimit(limit + 20)}
286
+ onClick={() => setLimit(Math.min(limit + 20, 999))}
192
287
  disabled={isLoading}
193
288
  >
194
289
  {isLoading ? "Loading…" : "Load more"}
@@ -199,6 +294,50 @@ export function ScoreList() {
199
294
  }
200
295
  ```
201
296
 
297
+ ## Known limitations
298
+
299
+ - **Row limits and pagination:** `limit` defaults to 20 and is capped at 999. Queries have no cursor or offset. Increasing the limit repeats the query from the start and replaces the snapshot. `hasMore` can remain true at the cap, so use filters to narrow larger data sources.
300
+ - **Filter and sort rules:** Use one property condition or one `and` group with up to 25 conditions. Nested groups and `or` are unsupported. Sort by up to 10 unique properties. Empty values sort last in both directions. See the table below for property support and [Filters](#filters) for operators.
301
+ - **Changing a query:** TypeScript subscriptions copy their options when they start. To change them, unsubscribe and create a new subscription. React replaces the subscription when the key or options change. Previous rows are cleared while the new query loads.
302
+ - **Updates:** Callbacks receive complete snapshots, not individual row changes. A new result can trigger a callback even when its row values are unchanged.
303
+
304
+ ### Property support
305
+
306
+ The table shows the values returned by the Notion host and whether each property type supports filtering and sorting. ✅ means supported. ❌ means unsupported. ⚠️ marks a text fallback that does not preserve the property's structured value. Missing values can be `undefined`.
307
+
308
+ | Property type | Query result | Filter | Sort |
309
+ | --- | --- | :---: | :---: |
310
+ | `title` | Plain text | ✅ | ✅ |
311
+ | `rich_text` | Plain text | ✅ | ✅ |
312
+ | `number` | Number | ✅ | ✅ |
313
+ | `checkbox` | Boolean | ✅ | ✅ |
314
+ | `url` | String | ✅ | ✅ |
315
+ | `email` | String | ✅ | ✅ |
316
+ | `phone_number` | String | ✅ | ✅ |
317
+ | `select` | Option name | ✅ | ❌ |
318
+ | `multi_select` | Array of option names | ✅ | ❌ |
319
+ | `status` | Option name | ✅ | ❌ |
320
+ | `date` | `NotionDateValue` | ✅ | ✅ |
321
+ | `people` | Array of record pointers | ❌ | ❌ |
322
+ | `files` | ⚠️ Text fallback | ❌ | ❌ |
323
+ | `unique_id` | ⚠️ Text fallback | ❌ | ❌ |
324
+ | `relation` | Array of record pointers | ❌ | ❌ |
325
+ | `place` | ⚠️ Text fallback | ❌ | ❌ |
326
+ | `formula` | ⚠️ Text fallback | ❌ | ❌ |
327
+ | `rollup` | ⚠️ Text fallback | ❌ | ❌ |
328
+ | `button` | ⚠️ Text fallback | ❌ | ❌ |
329
+ | `verification` | ⚠️ Text fallback | ❌ | ❌ |
330
+ | `last_visited_time` | ⚠️ Text fallback | ❌ | ❌ |
331
+ | `location` | ⚠️ Text fallback | ❌ | ❌ |
332
+ | `created_time` | `NotionDateTime` in UTC | ❌ | ✅ |
333
+ | `last_edited_time` | `NotionDateTime` in UTC | ❌ | ✅ |
334
+ | `created_by` | Array of record pointers | ❌ | ❌ |
335
+ | `last_edited_by` | Array of record pointers | ❌ | ❌ |
336
+
337
+ Text values omit formatting, mention tokens, and annotations. Record pointers contain a table name and a record ID.
338
+
339
+ Use `propertyId: "created_time"` or `propertyId: "last_edited_time"` to sort by these built-in timestamps.
340
+
202
341
  ## Types
203
342
 
204
343
  ### Rows & values
@@ -207,14 +346,15 @@ export function ScoreList() {
207
346
  - `NotionDataSourcePage` — a single row exposed to app code: `{ id, propertiesById, propertiesByKey, update, archive, unarchive }`.
208
347
  - `NotionDataSourceValue` — the value union for `propertiesById` and `propertiesByKey`.
209
348
  - `NotionDataSourcePageUpdateArgs` / `UpdatePageResult` — arguments and result for the per-page `update` helper.
210
- - `NotionDataSourcePageUpdateInput` — deprecated alias for `NotionDataSourcePageUpdateArgs`.
211
- - `NotionDataSourcePageUpdateResult` — deprecated alias for `UpdatePageResult`.
212
349
  - `UseDataSourceOptions` — options accepted by `useDataSource`: `limit?: number`, `filter?: NotionDataSourceFilter`, and `sorts?: NotionDataSourceSort[]`.
213
350
  - `NotionDataSourceFilter` — one property condition or one shallow `and` group.
214
351
  - `NotionDataSourcePropertyFilter` — one property address combined with a type-specific operator.
215
352
  - `NotionDataSourcePropertyAddress` — either a semantic property `key` or a raw `propertyId`.
216
353
  - `NotionDataSourceSort` — a property address with an `ascending` or `descending` direction.
217
354
  - `NotionDataSourceTextFilterOperator`, `NotionDataSourceNumberFilterOperator`, `NotionDataSourceCheckboxFilterOperator`, `NotionDataSourceOptionFilterOperator`, `NotionDataSourceContainsFilterOperator`, and `NotionDataSourceDateFilterOperator` — the operators accepted for each property type.
355
+ - `DataSourceSnapshot` — current rows, schema, and status for one live data-source query.
356
+ - `DataSourceQueryOptions` — the row limit, filter, and sorts shared by both query APIs.
357
+ - `SubscribeToDataSourceArgs` — the key, `onSnapshot` listener, and optional query options for `customBlock.subscribeToDataSource`.
218
358
 
219
359
  ### Property schemas
220
360
 
package/docs/lifecycle.md CHANGED
@@ -150,7 +150,7 @@ All code types are open string unions, so keep a default branch. Use `isRetryabl
150
150
 
151
151
  Framework-neutral runtime APIs for renderers that do not use React hooks. `customBlock.getState()` returns a `CustomBlockState` snapshot that includes `theme` and `contrastMode` while hiding internal query cache details. All getters that read host state — `getTheme`, `getContrastMode`, `getBlockId`, `getParent`, `getPage`, `getCurrentUser`, and `getManifest` — throw until `initCustomBlock()` resolves. `getManifest()` then returns the authoritative manifest supplied by the host.
152
152
 
153
- `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.
153
+ `customBlock` covers runtime state, live data source snapshots, and sizing. Imperative APIs such as `pages.*` and `users.*` are also framework-neutral functions.
154
154
 
155
155
  ```ts
156
156
  await initCustomBlock();
@@ -177,16 +177,6 @@ stopAutoResize();
177
177
 
178
178
  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.
179
179
 
180
- ### `useCustomBlockAutoResize({ enabled? })`
181
-
182
- Deprecated. Blocks resize automatically. Use CSS `max-height` and `overflow` to set a shorter height.
183
-
184
- ```ts
185
- function useCustomBlockAutoResize(args?: { enabled?: boolean }): void;
186
- ```
187
-
188
- Deprecated React wrapper around `customBlock.autoResize({ target: document.getElementById("root") })`. It measures `#root`'s height and posts `resize` messages, deduping unchanged values. `<NotionCustomBlock>` runs this automatically.
189
-
190
180
  ## Debug console
191
181
 
192
182
  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:
package/docs/pages.md CHANGED
@@ -161,7 +161,5 @@ Do **not** send `{ type: "file_upload", file_upload: { id } }`; the host will re
161
161
  - `NotionPagePropertyWriteMap` — input map for `pages.update` (raw property IDs only).
162
162
  - `NotionCreatePagePosition` — `start` / `end` / `before` / `after` insertion variants.
163
163
  - `CreatePageArgs` / `CreatePageParent` / `CreatePageResult`.
164
- - `CreatePageInput` — deprecated alias for `CreatePageArgs`.
165
164
  - `GetPageResult`.
166
165
  - `UpdatePageArgs` / `UpdatePageResult`.
167
- - `UpdatePageInput` — deprecated alias for `UpdatePageArgs`.
package/docs/users.md CHANGED
@@ -78,5 +78,4 @@ if (result.status === "success") {
78
78
  - `useCurrentUser()` — hook that returns the viewer's `NotionUser`.
79
79
  - `customBlock.getCurrentUser()` — framework-neutral getter for the viewer's `NotionUser`.
80
80
  - `ListUsersArgs` / `ListUsersResult`.
81
- - `ListUsersInput` — deprecated alias for `ListUsersArgs`.
82
81
  - `GetUserResult`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@notionhq/custom-blocks",
3
- "version": "0.1.43",
3
+ "version": "0.1.44",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -32,6 +32,7 @@ import type { ListUsersMessage } from "@notionhq/custom-blocks-protocol/messages
32
32
  import type { QueryDataSourceMessage } from "@notionhq/custom-blocks-protocol/messages/queryDataSource.js"
33
33
  import type { CustomBlockQueryDataSourceErrorInfo } from "@notionhq/custom-blocks-protocol/messages/queryDataSourceResult.js"
34
34
  import type { ResizeMessage } from "@notionhq/custom-blocks-protocol/messages/resize.js"
35
+ import type { UnsubscribeDataSourceQueryMessage } from "@notionhq/custom-blocks-protocol/messages/unsubscribeDataSourceQuery.js"
35
36
  import type { UpdatePageMessage } from "@notionhq/custom-blocks-protocol/messages/updatePage.js"
36
37
  import { CUSTOM_BLOCK_BRIDGE_PROTOCOL_VERSION } from "@notionhq/custom-blocks-protocol/protocolVersion.js"
37
38
  import type { NotionTheme } from "@notionhq/custom-blocks-protocol/theme.js"
@@ -40,6 +41,7 @@ import { globalPrimedResponsesCache } from "../testing.js"
40
41
  import type {
41
42
  CreatePageArgs,
42
43
  CreatePageResult,
44
+ DataSourceQueryOptions,
43
45
  GetPageResult,
44
46
  GetUserResult,
45
47
  ListUsersArgs,
@@ -48,7 +50,6 @@ import type {
48
50
  NotionUserId,
49
51
  UpdatePageArgs,
50
52
  UpdatePageResult,
51
- UseDataSourceOptions,
52
53
  } from "../types.js"
53
54
  import { unreachable } from "../utils.js"
54
55
  import { CUSTOM_BLOCKS_SDK_VERSION } from "../version.js"
@@ -65,6 +66,7 @@ import {
65
66
  type DataSourceQueryState,
66
67
  } from "./hostState.js"
67
68
  import type { ManifestLoadResult } from "./loadManifest.js"
69
+ import { notifyListener } from "./notifyListener.js"
68
70
  import { PendingRequests } from "./pendingRequests.js"
69
71
 
70
72
  /**
@@ -88,6 +90,14 @@ const RESPONSE_TYPE_BY_REQUEST = new Map([
88
90
 
89
91
  const INIT_RESULT_TIMER_FALLBACK_MS = 100
90
92
 
93
+ export type QueryDataSourceArgs = {
94
+ subscriptionId: string
95
+ key: string
96
+ options?: DataSourceQueryOptions
97
+ /** Send the query even if an identical query is already loading. */
98
+ forceRequery?: boolean
99
+ }
100
+
91
101
  export class SandboxBridge {
92
102
  private hostState: CustomBlockHostState = {
93
103
  status: "uninitialized",
@@ -281,7 +291,7 @@ export class SandboxBridge {
281
291
 
282
292
  private notify = () => {
283
293
  for (const listener of this.listeners) {
284
- listener()
294
+ notifyListener(listener)
285
295
  }
286
296
  }
287
297
 
@@ -437,15 +447,27 @@ export class SandboxBridge {
437
447
  nextBindings,
438
448
  })
439
449
  this.latestDataSourceBindings = nextBindings
440
- // Drop cached query state for subscriptions whose key no longer exists.
441
- const nextKeys = new Set(dataSources.map(s => s.key))
450
+ // Preserve rows only while the key still refers to the same backing data source.
451
+ const previousSourcesByKey = new Map(
452
+ hostState.dataSources.map(source => [source.key, source]),
453
+ )
454
+ const nextSourcesByKey = new Map(
455
+ dataSources.map(source => [source.key, source]),
456
+ )
442
457
  const prunedState: Record<string, DataSourceQueryState> = {}
443
458
  for (const [subscriptionId, state] of Object.entries(
444
459
  hostState.dataSourceState,
445
460
  )) {
446
- if (nextKeys.has(state.dataSourceKey)) {
447
- prunedState[subscriptionId] = state
461
+ const nextSource = nextSourcesByKey.get(state.dataSourceKey)
462
+ if (nextSource === undefined) {
463
+ continue
448
464
  }
465
+ const previousSource = previousSourcesByKey.get(state.dataSourceKey)
466
+ prunedState[subscriptionId] =
467
+ previousSource?.collectionPointer?.id ===
468
+ nextSource.collectionPointer?.id
469
+ ? state
470
+ : createEmptyDataSourceQueryState(state.dataSourceKey)
449
471
  }
450
472
  this.hostState = {
451
473
  ...hostState,
@@ -720,11 +742,12 @@ export class SandboxBridge {
720
742
  return `data-source:${globalThis.crypto.randomUUID()}`
721
743
  }
722
744
 
723
- queryDataSource(
724
- subscriptionId: string,
725
- key: string,
726
- options: UseDataSourceOptions = {},
727
- ) {
745
+ queryDataSource({
746
+ subscriptionId,
747
+ key,
748
+ options = {},
749
+ forceRequery = false,
750
+ }: QueryDataSourceArgs) {
728
751
  if (this.hostState.status !== "initialized") {
729
752
  return
730
753
  }
@@ -770,6 +793,7 @@ export class SandboxBridge {
770
793
  const query = resolvedQuery.query
771
794
 
772
795
  if (
796
+ !forceRequery &&
773
797
  currentState.isLoading &&
774
798
  currentState.latestQueryIdentity === query.identity
775
799
  ) {
@@ -790,8 +814,6 @@ export class SandboxBridge {
790
814
  },
791
815
  },
792
816
  }
793
- this.notify()
794
-
795
817
  const outbound: QueryDataSourceMessage = {
796
818
  type: "queryDataSource",
797
819
  subscriptionId,
@@ -801,6 +823,7 @@ export class SandboxBridge {
801
823
  ...(query.sorts !== undefined ? { sorts: query.sorts } : {}),
802
824
  }
803
825
  this.postToHost(outbound)
826
+ this.notify()
804
827
  }
805
828
 
806
829
  private setDataSourceQueryError(
@@ -840,6 +863,11 @@ export class SandboxBridge {
840
863
  ...this.hostState,
841
864
  dataSourceState,
842
865
  }
866
+ const outbound: UnsubscribeDataSourceQueryMessage = {
867
+ type: "unsubscribeDataSourceQuery",
868
+ subscriptionId,
869
+ }
870
+ this.postToHost(outbound)
843
871
  }
844
872
 
845
873
  postResize(height: number) {