Skip to content

MILAB-5607: public data for plugins#1534

Open
erohinaelena wants to merge 2 commits intomainfrom
MILAB-5607-public-data-for-plugins
Open

MILAB-5607: public data for plugins#1534
erohinaelena wants to merge 2 commits intomainfrom
MILAB-5607-public-data-for-plugins

Conversation

@erohinaelena
Copy link
Copy Markdown
Member

@erohinaelena erohinaelena commented Mar 30, 2026

Greptile Summary

This PR introduces a publicData feature for plugins, allowing plugin authors to declaratively expose fields from their internal data object as a typed, reactive surface accessible on app.plugins[id].publicData and through usePlugin(). The implementation threads a new PublicData generic through the entire plugin type stack (PluginFactoryLikePluginFactoryPluginModelPluginRecordInferPluginHandles) and wires the runtime in createAppV3 using Vue computed refs wrapped inside reactive().

Key changes:

  • sdk/model: New PublicFieldDef/PublicDataDef types; fluent .publicData(def) builder step on PluginModelBuilder; publicDataDef propagated through PluginModelFactory and serialized into BlockModelInfo.pluginPublicData.
  • sdk/ui-vue: createAppV3 reads blockModelInfo.pluginPublicData, creates a getter-only or getter/setter computed ref per field, and wraps them in a reactive() object so Vue auto-unwraps them at access sites. The result is placed on PluginState.publicData returned by usePlugin() and pre-populated in the app.plugins map.
  • Tests: Existing fixtures updated to include pluginPublicData: {} — no new tests exercise the getter/setter/lens paths or the reactive update flow for the new feature.

Confidence Score: 5/5

Safe to merge; all findings are P2 quality-of-life concerns that do not block correctness on the primary path.

The core type machinery is carefully constructed and consistent throughout the stack. The Vue reactive pattern (computed refs inside reactive()) is idiomatic Vue 3 and correct. The three flagged issues are all P2: a defensive null guard on a code path that TypeScript already prevents at compile time, a silent no-op setter that matches how the getter already behaves, and an eager-vs-lazy initialization change that is benign in practice since the watcher won't fire unless data actually mutates.

sdk/ui-vue/src/internal/createAppV3.ts — contains all three flagged points; the eager plugin-state initialization deserves a deliberate sign-off before merge.

Important Files Changed

Filename Overview
sdk/ui-vue/src/internal/createAppV3.ts Core Vue integration: computes reactive publicData via computed refs wrapped in reactive(); three issues flagged — null-safety of typeof fieldDef === "object" check, silent no-op setter when data is null, and eager state creation for all plugins changing previous lazy behaviour.
sdk/model/src/plugin_model.ts Adds PublicFieldDef/PublicDataDef types and a fluent .publicData() builder step to PluginModelBuilder; propagates PublicData generic through PluginModel, PluginInstance, and PluginModelFactory. Changes look correct and consistent.
sdk/model/src/platforma.ts Adds pluginPublicData field to BlockModelInfo; extends InferPluginHandles to include readonly publicData: PublicData and forwards the new generic in InferPluginData/InferPluginHandles. Clean and correct.
sdk/model/src/plugin_handle.ts Adds PublicData to PluginFactoryLike phantom and exports InferFactoryPublicData extractor. Straightforward addition, consistent with existing pattern for Data/Params/Outputs.
sdk/ui-vue/src/usePlugin.ts Adds PublicData generic to PluginState and exposes readonly publicData: PublicData field. Minimal and correct.
sdk/model/src/block_model.ts Adds PPublicData generic to plugin() method and PluginRecord; populates pluginPublicData in BlockModelInfo using publicDataDef ?? {}. Logic is correct.
sdk/model/src/block_model_legacy.ts Adds pluginPublicData: {} to the legacy V2 block's BlockModelInfo. Correct empty placeholder since V2 has no plugins.
sdk/model/src/index.ts Exports the two new public types PublicFieldDef and PublicDataDef from the package.
sdk/ui-vue/src/internal/createAppV3.test.ts Adds pluginPublicData: {} to all existing test fixtures; no new tests exercise the actual publicData reactive behaviour or the lens/getter distinction.
sdk/ui-vue/src/internal/createAppV2.test.ts Adds pluginPublicData: {} to V2 test fixtures to keep them compatible with the updated type signature.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: sdk/ui-vue/src/internal/createAppV3.ts
Line: 395-423

Comment:
**`reactive()` wraps computed refs – setter silently discarded on null data**

When `pluginModel.data` is `null` (plugin not yet initialized), calling a writable `publicData` setter does nothing (`if (data != null) setter(data, v)` silently no-ops). This is intentional in the getter branch, but for the setter the mutation is lost forever — Vue won't retry it and no error is raised. Callers have no way to know the write was dropped.

Consider throwing or queuing the write instead of silently dropping it:

```ts
set: (v: unknown) => {
  const data = pluginModel.data;
  if (data == null) throw new Error(`publicData setter called before plugin data is initialized`);
  setter(data, v);
},
```

Or at minimum document the behaviour so callers know a write before data is ready is silently discarded.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: sdk/ui-vue/src/internal/createAppV3.ts
Line: 449-454

Comment:
**Eager plugin state initialization changes prior lazy behaviour**

Previously, the `plugins` map was built with just `{ handle: id }` — plugin states (including `watchIgnorable` watchers and computed refs) were created lazily on first call to `usePlugin()`. This PR changes that: every plugin in `pluginIds` now has its full internal state (reactive model, deep watcher, computed `publicData` refs) created during `createAppV3` initialization, even if the plugin is never accessed.

The deep watcher on `pluginModel.data` will now fire on data changes for all plugins, not only those the block's UI actually uses. In a block with many plugins this could cause unnecessary `updatePluginData` round-trips.

Consider keeping the lazy path and instead computing `publicData` only when accessed, or at least documenting that state is now created eagerly so future maintainers are aware of the change.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: sdk/ui-vue/src/internal/createAppV3.ts
Line: 398

Comment:
**`typeof null === "object"` can cause a silent runtime error**

`typeof fieldDef === "object"` returns `true` for `null` in JavaScript. If `null` somehow ends up as a value in `rawDef` (e.g. JSON-deserialized `pluginPublicData` over a network or a bad cast), `getter` would be assigned `null.get`, causing a "Cannot read properties of null" runtime error with no helpful message.

```suggestion
          const isLens = fieldDef !== null && typeof fieldDef === "object";
```

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "add changeset" | Re-trigger Greptile

Greptile also left 3 inline comments on this PR.

(4/5) You can add custom instructions or style guidelines for the agent here!

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Mar 30, 2026

🦋 Changeset detected

Latest commit: 670c595

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 15 packages
Name Type
@platforma-sdk/ui-vue Patch
@platforma-sdk/model Patch
@milaboratories/milaboratories.monetization-test.ui Patch
@milaboratories/milaboratories.ui-examples.ui Patch
@milaboratories/milaboratories.pool-explorer.ui Patch
@milaboratories/pl-middle-layer Patch
@milaboratories/uikit Patch
@milaboratories/milaboratories.monetization-test.model Patch
@milaboratories/milaboratories.ui-examples.model Patch
@milaboratories/milaboratories.ui-examples Patch
@milaboratories/milaboratories.pool-explorer.model Patch
@milaboratories/milaboratories.pool-explorer Patch
@milaboratories/milaboratories.monetization-test Patch
@platforma-sdk/pl-cli Patch
@platforma-sdk/test Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@notion-workspace
Copy link
Copy Markdown

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces the publicData feature for plugins, allowing developers to expose specific plugin data fields as reactive properties through getters or lenses. The implementation includes updates to the SDK model, factory, and builder classes to support the new PublicData generic, along with logic in the Vue integration to handle reactive state reconciliation. A review comment identifies an opportunity to improve type safety in the PluginModelBuilder by removing redundant type assertions and avoiding the use of any.

Comment on lines +709 to +717
return new PluginModelBuilder<Data, Params, Outputs, Config, Versions, NewPD>({
name: this.name,
dataFn: this.dataFn,
getDefaultDataFn: this.getDefaultDataFn,
featureFlags: this.featureFlags,
outputs: this.outputs as any,
outputFlags: this.outputFlags,
publicDataDef: def as PublicDataDef<Data, NewPD>,
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The outputs property is being cast to any, which reduces type safety. Since the Outputs generic parameter isn't changing when creating the new PluginModelBuilder instance, this.outputs should be assignable directly without a cast. If a cast is truly necessary due to a subtle type inference issue, a more specific assertion should be used instead of any.

Additionally, the cast on publicDataDef is redundant as the type of def already matches PublicDataDef<Data, NewPD> and can be removed.

    return new PluginModelBuilder<Data, Params, Outputs, Config, Versions, NewPD>({
      name: this.name,
      dataFn: this.dataFn,
      getDefaultDataFn: this.getDefaultDataFn,
      featureFlags: this.featureFlags,
      outputs: this.outputs,
      outputFlags: this.outputFlags,
      publicDataDef: def,
    });

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

outputs: this.outputs as any, - please fix

Comment on lines +395 to +423
const publicData = reactive(
Object.fromEntries(
Object.entries(rawDef).map(([key, fieldDef]) => {
const isLens = typeof fieldDef === "object";
const getter = isLens
? (fieldDef as { get: (d: unknown) => unknown }).get
: (fieldDef as (d: unknown) => unknown);
const setter = isLens
? (fieldDef as { set?: (d: unknown, v: unknown) => void }).set
: undefined;
const ref = setter
? computed({
get: () => {
const data = pluginModel.data;
return data != null ? getter(data) : undefined;
},
set: (v: unknown) => {
const data = pluginModel.data;
if (data != null) setter(data, v);
},
})
: computed(() => {
const data = pluginModel.data;
return data != null ? getter(data) : undefined;
});
return [key, ref];
}),
),
) as InferFactoryPublicData<F>;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 reactive() wraps computed refs – setter silently discarded on null data

When pluginModel.data is null (plugin not yet initialized), calling a writable publicData setter does nothing (if (data != null) setter(data, v) silently no-ops). This is intentional in the getter branch, but for the setter the mutation is lost forever — Vue won't retry it and no error is raised. Callers have no way to know the write was dropped.

Consider throwing or queuing the write instead of silently dropping it:

set: (v: unknown) => {
  const data = pluginModel.data;
  if (data == null) throw new Error(`publicData setter called before plugin data is initialized`);
  setter(data, v);
},

Or at minimum document the behaviour so callers know a write before data is ready is silently discarded.

Prompt To Fix With AI
This is a comment left during a code review.
Path: sdk/ui-vue/src/internal/createAppV3.ts
Line: 395-423

Comment:
**`reactive()` wraps computed refs – setter silently discarded on null data**

When `pluginModel.data` is `null` (plugin not yet initialized), calling a writable `publicData` setter does nothing (`if (data != null) setter(data, v)` silently no-ops). This is intentional in the getter branch, but for the setter the mutation is lost forever — Vue won't retry it and no error is raised. Callers have no way to know the write was dropped.

Consider throwing or queuing the write instead of silently dropping it:

```ts
set: (v: unknown) => {
  const data = pluginModel.data;
  if (data == null) throw new Error(`publicData setter called before plugin data is initialized`);
  setter(data, v);
},
```

Or at minimum document the behaviour so callers know a write before data is ready is silently discarded.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines 449 to 454
const plugins = Object.fromEntries(
platforma.blockModelInfo.pluginIds.map((id) => [id, { handle: id }]),
platforma.blockModelInfo.pluginIds.map((id) => {
const { publicData } = pluginAccess.getOrCreatePluginState(id as PluginHandle);
return [id, { handle: id as PluginHandle, publicData }];
}),
) as InferPluginHandles<Plugins>;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Eager plugin state initialization changes prior lazy behaviour

Previously, the plugins map was built with just { handle: id } — plugin states (including watchIgnorable watchers and computed refs) were created lazily on first call to usePlugin(). This PR changes that: every plugin in pluginIds now has its full internal state (reactive model, deep watcher, computed publicData refs) created during createAppV3 initialization, even if the plugin is never accessed.

The deep watcher on pluginModel.data will now fire on data changes for all plugins, not only those the block's UI actually uses. In a block with many plugins this could cause unnecessary updatePluginData round-trips.

Consider keeping the lazy path and instead computing publicData only when accessed, or at least documenting that state is now created eagerly so future maintainers are aware of the change.

Prompt To Fix With AI
This is a comment left during a code review.
Path: sdk/ui-vue/src/internal/createAppV3.ts
Line: 449-454

Comment:
**Eager plugin state initialization changes prior lazy behaviour**

Previously, the `plugins` map was built with just `{ handle: id }` — plugin states (including `watchIgnorable` watchers and computed refs) were created lazily on first call to `usePlugin()`. This PR changes that: every plugin in `pluginIds` now has its full internal state (reactive model, deep watcher, computed `publicData` refs) created during `createAppV3` initialization, even if the plugin is never accessed.

The deep watcher on `pluginModel.data` will now fire on data changes for all plugins, not only those the block's UI actually uses. In a block with many plugins this could cause unnecessary `updatePluginData` round-trips.

Consider keeping the lazy path and instead computing `publicData` only when accessed, or at least documenting that state is now created eagerly so future maintainers are aware of the change.

How can I resolve this? If you propose a fix, please make it concise.

const publicData = reactive(
Object.fromEntries(
Object.entries(rawDef).map(([key, fieldDef]) => {
const isLens = typeof fieldDef === "object";
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 typeof null === "object" can cause a silent runtime error

typeof fieldDef === "object" returns true for null in JavaScript. If null somehow ends up as a value in rawDef (e.g. JSON-deserialized pluginPublicData over a network or a bad cast), getter would be assigned null.get, causing a "Cannot read properties of null" runtime error with no helpful message.

Suggested change
const isLens = typeof fieldDef === "object";
const isLens = fieldDef !== null && typeof fieldDef === "object";
Prompt To Fix With AI
This is a comment left during a code review.
Path: sdk/ui-vue/src/internal/createAppV3.ts
Line: 398

Comment:
**`typeof null === "object"` can cause a silent runtime error**

`typeof fieldDef === "object"` returns `true` for `null` in JavaScript. If `null` somehow ends up as a value in `rawDef` (e.g. JSON-deserialized `pluginPublicData` over a network or a bad cast), `getter` would be assigned `null.get`, causing a "Cannot read properties of null" runtime error with no helpful message.

```suggestion
          const isLens = fieldDef !== null && typeof fieldDef === "object";
```

How can I resolve this? If you propose a fix, please make it concise.

* zoom: { get: (d) => d.zoom, set: (d, v) => { d.zoom = v } }, // primitive — lens
* })
*/
publicData<NewPD extends Record<string, unknown>>(def: {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

don't use one letter short names

{ deep: true },
);

const rawDef = platforma.blockModelInfo.pluginPublicData[handle as string] ?? {};
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pluginPublicData have incorrect type, it's reason why u need a cast for handle

);

const rawDef = platforma.blockModelInfo.pluginPublicData[handle as string] ?? {};
const publicData = reactive(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reactive is unnecessary construction

? (fieldDef as { set?: (d: unknown, v: unknown) => void }).set
: undefined;
const ref = setter
? computed({
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I strictly against versus public data setter

Outputs extends PluginOutputs,
Config extends PluginConfig,
Versions extends Record<string, unknown> = {},
PublicData extends Record<string, unknown> = Record<string, unknown>,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add PluginPublicData and reuse it here

@codecov
Copy link
Copy Markdown

codecov Bot commented Mar 30, 2026

Codecov Report

❌ Patch coverage is 88.88889% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 54.46%. Comparing base (fda60b3) to head (670c595).
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
sdk/model/src/plugin_model.ts 88.88% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1534      +/-   ##
==========================================
- Coverage   54.59%   54.46%   -0.14%     
==========================================
  Files         250      250              
  Lines       14463    14467       +4     
  Branches     2998     2998              
==========================================
- Hits         7896     7879      -17     
- Misses       5564     5588      +24     
+ Partials     1003     1000       -3     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

const existing = pluginStates.get(handle);
if (existing) {
return existing as unknown as PluginState<InferFactoryData<F>, InferFactoryOutputs<F>>;
return existing as unknown as PluginState<
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why we need this cast?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add tests for you case, it would be great to see, how this api will work

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants