feat: let a host render the market's own panel (#602) (#633)

The Tauri desktop shell's answer to the last round was that status queries
alone do not help them — their purpose is to consume the market's PANEL,
which they render inside their own container alongside their own chrome.
That is a smaller thing than the slot inversion I declined, and it is the
fallback their issue had already proposed.

`market.render(props?)` returns the market's panel, error boundary
included, as a React element. Same page and same React instance: this
package's client bundle resolves react through the host's module table, so
the element mounts anywhere in that tree.

The builder moved into `src/client/market-element.ts`, taking its
dependencies as arguments rather than closing over the cordis context, for
two reasons:

- the settings section and `render()` must not drift. They pass different
  `preferredSubsectionId`s and the same everything else, and a mix-up would
  show up as a panel opening on the wrong tab in one of the two places, on
  somebody's machine;
- wiring that only a running host can reveal is wiring a test cannot check.
  The spec asserts which translate function, locale, theme store and log
  exporter reach the panel, and that the recovery panel's log button calls
  the exporter it was handed.

`ctx.provide` and `ctx.reflect.provide` are the same call — cordis's
Service.provide delegates to the reflect layer — so the reporter's own
idiom reaches this service. Documented in UPDATE-API-V1.md.

Deliberately still not done: host-fillable slots inside the market. One
host asking does not yet say what the second one would need.
This commit is contained in:
fkysly
2026-09-18 10:20:40 +08:00
committed by GitHub
parent 355c1d4894
commit 53f793e775
5 changed files with 316 additions and 51 deletions
+31
View File
@@ -154,6 +154,37 @@ same-origin, no forwarding headers, no package mutation in progress, and a Host
whose lifecycle is not owned by Desktop or a supervisor. Clients must feature
detect it; they must not invent an alternative process-control path.
## Rendering the market's panel elsewhere (client-side)
A host shell that wants the market inside its own container — rather than in
the settings page — reads the client service this package publishes:
```ts
const market = ctx.reflect.get('market')
const element = market.render({ preferredSubsectionId: 'installed' })
```
`render()` returns the market's own panel wrapped in its error boundary, as a
React element. Same page, same React instance: this package's client bundle
resolves react through the host's module table, so the element mounts
anywhere in that tree.
| member | |
|---|---|
| `version` | `1` |
| `render(props?)` | the panel element; `preferredSubsectionId` is optional |
| `setSettingsVisible(visible)` | register or retract the market's own `settings.section` entry |
| `settingsVisible()` | whether that entry is registered right now |
`ctx.provide(name, value)` and `ctx.reflect.provide(name, value)` are the
same call — cordis's `Service.provide` delegates to the reflect layer — so
either idiom reaches this service.
What `render()` is **not**: a way to rearrange the market. It hands over the
whole panel, chrome included. Cutting the market into host-fillable regions
is a different design, and one host asking is not yet evidence that it fits
anyone else.
## Compatibility policy
- New optional response fields may be added within v1.
+76 -24
View File
@@ -10720,6 +10720,44 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
});
}
//#endregion
//#region src/client/market-element.ts
/**
* The market's panel, as an element, built from explicit dependencies.
*
* Two callers want the same thing and must not drift apart:
*
* - the `settings.section` this package registers, whose slot passes down a
* host-chosen `preferredSubsectionId`;
* - `market.render()`, for a host shell that renders the market inside its
* own container (#602, from the Tauri desktop).
*
* A module function taking its dependencies rather than a closure over the
* cordis context, so the wiring — which locale, which theme, which log
* exporter reaches the panel — is something a test can assert instead of
* something only a running host can reveal.
*/
/**
* @param props - see {@link MarketElementProps}.
* @returns the panel wrapped in its error boundary.
*/
function marketElement(props) {
return (0, react.createElement)(MarketErrorBoundary, {
text: props.crashText,
actions: (0, react.createElement)("button", {
type: "button",
onClick: () => {
props.exportLog();
}
}, props.t("exportLog"))
}, (0, react.createElement)(MarketSection, {
t: props.t,
locale: props.locale,
theme: props.theme,
themeStore: props.themeStore,
preferredSubsectionId: props.preferredSubsectionId
}));
}
//#endregion
//#region src/client/section-gate.ts
/**
* @param register - registers the entry and returns its disposer. Called
@@ -11252,6 +11290,31 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
en
}), "dsh-market: dictionaries");
const t = ctx.locale.bind(NS);
/**
* The market's own panel, as an element — one builder for the settings
* section this package registers and for `market.render()` (#602). Built
* per call: the props are live (locale, theme, the host's preferred
* subsection), and a cached element would freeze the first caller's.
*/
const buildMarketElement = (ownerProps = {}) => marketElement({
t,
locale: ctx.locale,
theme: ctx.theme,
themeStore: {
subscribe: (cb) => ctx.on("theme/change", cb),
getSnapshot: () => ctx.theme.getTheme()
},
crashText: {
title: t("crashTitle"),
hint: t("crashHint"),
reload: t("crashReload"),
details: t("crashDetails")
},
exportLog: () => {
exportMarketLog().catch(() => {});
},
preferredSubsectionId: ownerProps.preferredSubsectionId
});
const sectionGate = createSectionGate(() => {
const off = ctx.slots.register({
name: "settings.section",
@@ -11260,29 +11323,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
label: () => t("nav"),
locale: NS,
inject: () => ({ t })
}, (ownerProps = {}) => (0, react.createElement)(MarketErrorBoundary, {
text: {
title: t("crashTitle"),
hint: t("crashHint"),
reload: t("crashReload"),
details: t("crashDetails")
},
actions: (0, react.createElement)("button", {
type: "button",
onClick: () => {
exportMarketLog().catch(() => {});
}
}, t("exportLog"))
}, (0, react.createElement)(MarketSection, {
t,
locale: ctx.locale,
theme: ctx.theme,
themeStore: {
subscribe: (cb) => ctx.on("theme/change", cb),
getSnapshot: () => ctx.theme.getTheme()
},
preferredSubsectionId: ownerProps.preferredSubsectionId
})));
}, (ownerProps = {}) => buildMarketElement(ownerProps));
return typeof off === "function" ? off : () => {};
});
ctx.slots.inject("settings.section", () => {
@@ -11293,7 +11334,18 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
setSettingsVisible: (visible) => {
sectionGate.setVisible(visible);
},
settingsVisible: () => sectionGate.visible()
settingsVisible: () => sectionGate.visible(),
/**
* The market's panel as an element, for a host that renders it inside
* its own container. Same page, same React instance — this package's
* bundle resolves react through the host's module table, so an element
* returned here mounts anywhere in that tree.
*
* What it is NOT: a way to rearrange the market. It hands over the whole
* panel, chrome included. Cutting the market into host-fillable regions
* is a different design and has not been asked for by a second host yet.
*/
render: (props = {}) => buildMarketElement(props)
};
if (typeof ctx.provide === "function") ctx.provide("market", marketControl);
ctx.inject(["settingsScope"], (scoped) => {
+37 -27
View File
@@ -11,6 +11,7 @@ import { en, zh } from './locales.ts'
import { InstallToast } from './InstallToast.tsx'
import { MarketErrorBoundary } from './ErrorBoundary.tsx'
import { MarketSection } from './MarketSection.tsx'
import { marketElement } from './market-element.ts'
import { createSectionGate } from './section-gate.ts'
import { exportMarketLog } from './self-check.ts'
import { SettingsCard } from './SettingsCard.tsx'
@@ -101,6 +102,30 @@ export function apply(ctx: MarketClientContext): void {
// it (#602: a shell that renders the market itself does not want a
// duplicate nav item) and whether this package is being removed. See
// section-gate.ts for why the orderings, not the booleans, are the work.
/**
* The market's own panel, as an element — one builder for the settings
* section this package registers and for `market.render()` (#602). Built
* per call: the props are live (locale, theme, the host's preferred
* subsection), and a cached element would freeze the first caller's.
*/
const buildMarketElement = (ownerProps: { preferredSubsectionId?: string } = {}): unknown => marketElement({
t,
locale: ctx.locale,
theme: ctx.theme,
themeStore: {
subscribe: (cb: () => void) => ctx.on('theme/change', cb),
getSnapshot: () => ctx.theme.getTheme(),
},
crashText: {
title: t('crashTitle'),
hint: t('crashHint'),
reload: t('crashReload'),
details: t('crashDetails'),
},
exportLog: () => { void exportMarketLog().catch(() => {}) },
preferredSubsectionId: ownerProps.preferredSubsectionId,
})
const sectionGate = createSectionGate(() => {
const off = ctx.slots.register({
name: 'settings.section',
@@ -109,33 +134,7 @@ export function apply(ctx: MarketClientContext): void {
label: () => t('nav'),
locale: NS,
inject: () => ({ t }),
}, (ownerProps: { preferredSubsectionId?: string } = {}) => h(MarketErrorBoundary, {
// Wrapped at the registration point, so the boundary is OUTSIDE
// everything the section renders — including its portalled layers.
// A crash inside used to unmount the whole tree and leave an empty
// settings panel with no export-log button, which is how #293 went
// months without a usable report (#513 fixed that trigger; this
// covers the next one).
text: {
title: t('crashTitle'),
hint: t('crashHint'),
reload: t('crashReload'),
details: t('crashDetails'),
},
actions: h('button', {
type: 'button',
onClick: () => { void exportMarketLog().catch(() => {}) },
}, t('exportLog')),
}, h(MarketSection, {
t,
locale: ctx.locale,
theme: ctx.theme,
themeStore: {
subscribe: (cb: () => void) => ctx.on('theme/change', cb),
getSnapshot: () => ctx.theme.getTheme(),
},
preferredSubsectionId: ownerProps.preferredSubsectionId,
})))
}, (ownerProps: { preferredSubsectionId?: string } = {}) => buildMarketElement(ownerProps))
// `slots.register` may not hand back a disposer on every host; the gate
// needs one regardless, so the absence becomes a no-op rather than a
// silently unretractable entry.
@@ -154,6 +153,17 @@ export function apply(ctx: MarketClientContext): void {
version: 1 as const,
setSettingsVisible: (visible: boolean): void => { sectionGate.setVisible(visible) },
settingsVisible: (): boolean => sectionGate.visible(),
/**
* The market's panel as an element, for a host that renders it inside
* its own container. Same page, same React instance — this package's
* bundle resolves react through the host's module table, so an element
* returned here mounts anywhere in that tree.
*
* What it is NOT: a way to rearrange the market. It hands over the whole
* panel, chrome included. Cutting the market into host-fillable regions
* is a different design and has not been asked for by a second host yet.
*/
render: (props: { preferredSubsectionId?: string } = {}): unknown => buildMarketElement(props),
}
// Guarded: `provide` is cordis's, and a host old enough to be missing it
// should lose the control surface, not the whole market.
+60
View File
@@ -0,0 +1,60 @@
/**
* The market's panel, as an element, built from explicit dependencies.
*
* Two callers want the same thing and must not drift apart:
*
* - the `settings.section` this package registers, whose slot passes down a
* host-chosen `preferredSubsectionId`;
* - `market.render()`, for a host shell that renders the market inside its
* own container (#602, from the Tauri desktop).
*
* A module function taking its dependencies rather than a closure over the
* cordis context, so the wiring — which locale, which theme, which log
* exporter reaches the panel — is something a test can assert instead of
* something only a running host can reveal.
*/
import { createElement as h } from 'react'
import { MarketErrorBoundary } from './ErrorBoundary.tsx'
import { MarketSection } from './MarketSection.tsx'
import type { MarketSectionProps } from './MarketSection.tsx'
import type { Translate } from './market-data.ts'
/** Everything the panel needs from the client context, plus its own actions. */
export interface MarketElementProps {
t: Translate
locale: MarketSectionProps['locale']
theme: MarketSectionProps['theme']
themeStore: MarketSectionProps['themeStore']
/** The error boundary's own copy; passed in so this file holds none. */
crashText: { title: string; hint: string; reload: string; details: string }
/** Export the log from the recovery panel — the button that must survive a crash. */
exportLog: () => void
/** Host-provided destination, when the caller has one. */
preferredSubsectionId?: string
}
/**
* @param props - see {@link MarketElementProps}.
* @returns the panel wrapped in its error boundary.
*/
export function marketElement(props: MarketElementProps): unknown {
return h(MarketErrorBoundary, {
// Wrapped HERE rather than at each call site, so the boundary is outside
// everything the panel renders — including its portalled layers — for
// every caller. A crash used to unmount the whole tree and leave an
// empty settings panel with no export-log button, which is how #293 went
// months without a usable report.
text: props.crashText,
actions: h('button', {
type: 'button',
onClick: () => { props.exportLog() },
}, props.t('exportLog')),
}, h(MarketSection, {
t: props.t,
locale: props.locale,
theme: props.theme,
themeStore: props.themeStore,
preferredSubsectionId: props.preferredSubsectionId,
}))
}
+112
View File
@@ -0,0 +1,112 @@
// @vitest-environment jsdom
/**
* The one builder behind both the settings section and `market.render()`
* (#602).
*
* This asserts the WIRING, not the panel: which translate function, which
* locale, which theme store, which log exporter reach `MarketSection`. That
* is the part two callers can silently disagree about — the section passes a
* host-chosen `preferredSubsectionId`, the render entry passes whatever its
* caller gave it, and a mix-up would only show up as a panel that opens on
* the wrong tab, in one of the two places, on somebody's machine.
*
* The panel itself is covered by market-section.client.spec.tsx.
*/
import { describe, expect, it, vi } from 'vitest'
import type { ReactElement } from 'react'
import { marketElement } from '../../src/client/market-element.ts'
import { MarketErrorBoundary } from '../../src/client/ErrorBoundary.tsx'
import { MarketSection } from '../../src/client/MarketSection.tsx'
interface SectionProps {
t: unknown
locale: unknown
theme: unknown
themeStore: unknown
preferredSubsectionId?: string
}
function build(overrides: Partial<Parameters<typeof marketElement>[0]> = {}): {
boundary: ReactElement<{ text: unknown; actions: unknown }>
section: ReactElement<SectionProps>
exportLog: ReturnType<typeof vi.fn>
} {
const exportLog = vi.fn()
const tree = marketElement({
t: ((key: string) => key) as never,
locale: { subscribe: () => () => {}, getSnapshot: () => ({ active: 'zh' }) },
theme: { setTheme: () => {} },
themeStore: { subscribe: () => () => {}, getSnapshot: () => null },
crashText: { title: 'T', hint: 'H', reload: 'R', details: 'D' },
exportLog,
...overrides,
}) as ReactElement<{ children: ReactElement<SectionProps> }>
return { boundary: tree as never, section: tree.props.children, exportLog }
}
describe('marketElement', () => {
it('wraps the panel in the error boundary, for every caller', () => {
// #293: a crash took the whole tree with it, including the export-log
// button, so the reports that mattered most could not be produced. A
// caller that mounted MarketSection directly would reintroduce that.
const { boundary, section } = build()
expect(boundary.type).toBe(MarketErrorBoundary)
expect(section.type).toBe(MarketSection)
})
it('passes the panel every dependency it reads', () => {
const t = (key: string) => `t:${key}`
const locale = { subscribe: () => () => {}, getSnapshot: () => ({ active: 'en' }) }
const theme = { setTheme: vi.fn() }
const themeStore = { subscribe: () => () => {}, getSnapshot: () => null }
const { section } = build({ t: t as never, locale, theme, themeStore })
expect(section.props.t).toBe(t)
expect(section.props.locale).toBe(locale)
expect(section.props.theme).toBe(theme)
expect(section.props.themeStore).toBe(themeStore)
})
it('forwards the host-chosen subsection, and leaves it undefined when absent', () => {
// The section's slot passes this down per open; the render entry passes
// whatever its caller gave it. Dropping it would send a host that asked
// for "installed" to the Discover tab instead.
expect(build({ preferredSubsectionId: 'installed' }).section.props.preferredSubsectionId).toBe('installed')
expect(build().section.props.preferredSubsectionId).toBeUndefined()
})
it('gives the recovery panel a working log button and its own copy', () => {
// The button must call the exporter it was handed — this is the control
// that has to survive a crash, so a no-op here is the whole feature gone.
const { boundary, exportLog } = build()
const actions = boundary.props.actions as ReactElement<{ onClick: () => void }>
actions.props.onClick()
expect(exportLog).toHaveBeenCalledTimes(1)
expect(boundary.props.text).toEqual({ title: 'T', hint: 'H', reload: 'R', details: 'D' })
})
it('builds a fresh element each time, not one shared tree', () => {
// Two live callers (the section and a host panel) with different props;
// a cached element would freeze whichever mounted first.
const first = marketElement({
t: ((k: string) => k) as never,
locale: { subscribe: () => () => {}, getSnapshot: () => ({ active: 'zh' }) },
theme: { setTheme: () => {} },
themeStore: { subscribe: () => () => {}, getSnapshot: () => null },
crashText: { title: 'T', hint: 'H', reload: 'R', details: 'D' },
exportLog: () => {},
preferredSubsectionId: 'a',
})
const second = marketElement({
t: ((k: string) => k) as never,
locale: { subscribe: () => () => {}, getSnapshot: () => ({ active: 'zh' }) },
theme: { setTheme: () => {} },
themeStore: { subscribe: () => () => {}, getSnapshot: () => null },
crashText: { title: 'T', hint: 'H', reload: 'R', details: 'D' },
exportLog: () => {},
preferredSubsectionId: 'b',
})
expect(first).not.toBe(second)
})
})