Skip to content

feat: add gdocs component for bespoke viz#6050

Merged
marcelgerber merged 24 commits intomasterfrom
gdocs-bespoke-component
Mar 11, 2026
Merged

feat: add gdocs component for bespoke viz#6050
marcelgerber merged 24 commits intomasterfrom
gdocs-bespoke-component

Conversation

@marcelgerber
Copy link
Member

@marcelgerber marcelgerber commented Feb 3, 2026

Adds a {.bespoke-component} component.

It has an in-code registry of component "bundles" (which are just pointers to JS & CSS URLs), and the JS entrypoint needs to export a

mount: (
    container: HTMLElement,
    opts: { variant?: string; config: Record<string, unknown> }
)

method.

The component is a client-only component; it currently doesn't do anything before hydration and just renders an (infinite) loading indicator.

What is variant?

The variant is an optional pointer for multiple visualizations to be part of the same bundle, but to be embedded in different spots inside the gdoc - but still being able to share state.
In theory, this could also be achieved by just using config, but I feel like it's a common-enough use case that we want to have an "approved" way to do this.

How this works

Technically, we load the JS file using dynamic await, and then call mount on a div we create inside of a Shadow DOM.
The CSS is also loaded into the Shadow DOM, and thus the bespoke component is fully encapsulated when it comes to CSS.

Drawbacks of Shadow DOM

This creates a bunch of small problems also, though: We need to use :host rather than :root inside the CSS to define global variables, and need to ensure that any code that mounts elements inside portals (e.g. tooltip code like floating-ui in my case) mounts its element inside the Shadow DOM, because if it gets mounted outside it doesn't have CSS styles.

Try it out

You can preview this here (this is the doc)

In the demo, you can easily see how the two charts are connected in state. It's easy to disconnect their state (but the bespoke implementation needs to handle that), but the ability to have them connected is really useful in some of our use cases.

TODOs

  • Remove test component before merging
  • Make the error message display more end-user-friendly

Open questions

  • What's a good way to support sizing, and avoid layout reflows?
  • What changes would we need to make to allow for articles containing bespoke components to be archived?
  • Should we remove the .script component now?
  • Should we add a way to mount a component without using Shadow DOM (as an option in the component registry; we can also easily do this later on)?

Screenshot

CleanShot 2026-02-03 at 17 16 51

@owidbot
Copy link
Contributor

owidbot commented Feb 3, 2026

Quick links (staging server):

Site Dev Site Preview Admin Wizard Docs

Login: ssh owid@staging-site-gdocs-bespoke-component

Archive:
SVG tester:

Number of differences (graphers): 0 ✅
Number of differences (grapher views): skipped
Number of differences (mdims): skipped
Number of differences (explorers): skipped
Number of differences (thumbnails): skipped

Edited: 2026-03-10 11:40:54 UTC
Execution time: 1.34 seconds

@marcelgerber marcelgerber force-pushed the gdocs-bespoke-component branch from 2310235 to 15faf3d Compare February 3, 2026 15:33
@marcelgerber marcelgerber marked this pull request as ready for review February 3, 2026 16:26
@marcelgerber marcelgerber requested a review from ikesau February 3, 2026 16:26
Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2ff8b3c623

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +74 to +77
// Create a container div inside the shadow root for the component to render into
const mountContainer = document.createElement("div")
mountContainer.className = "bespoke-container"
shadowRoot!.appendChild(mountContainer)

Choose a reason for hiding this comment

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

P2 Badge Clear shadow DOM on rehydrate to avoid duplicates

Every effect run appends a new mount container (and, earlier, a new <link> for CSS) into the same shadow root, but the cleanup only calls the disposer. When block.config/variant changes or the component remounts, old DOM/CSS can accumulate unless each bespoke bundle explicitly removes it, leading to duplicate visuals or leaked nodes. Consider reusing a single container or clearing the shadow root in cleanup before remounting.

Useful? React with 👍 / 👎.

Copy link
Member Author

Choose a reason for hiding this comment

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

How would I go about doing this?

Copy link
Member

Choose a reason for hiding this comment

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

Maybe you could still fix this, but it seems like... not a big deal to me, given that block.config and variant won't change, due to how we only ever hydrate the page once.

Copy link
Member Author

Choose a reason for hiding this comment

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

This is where the action is

Comment on lines +26 to +32
test: {
scriptUrl:
"https://owid-public.owid.io/marcel-bespoke-data-viz-02-2026/poverty-plots/income-plots.mjs",
cssUrls: [
"https://owid-public.owid.io/marcel-bespoke-data-viz-02-2026/poverty-plots/income-plots.css",
],
},
Copy link
Member Author

@marcelgerber marcelgerber Feb 3, 2026

Choose a reason for hiding this comment

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

This is an example registry entry; and what is powering the demo that you're seeing. I'll remove it before we merge the PR.

Copy link
Member

Choose a reason for hiding this comment

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

Right so we still need to figure out the way we write and generate these bundles. I guess for starters it doesn't need to be too systematized.

yield* propertyToArchieMLString("variant", block.value)
yield* propertyToArchieMLString("size", block.value)
if (block.value.config) {
// TODO this doesn't support deep nesting, which in theory the type Record<string, unknown> allows for
Copy link
Member

Choose a reason for hiding this comment

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

I wonder if it'd be smart to just decide now that nesting isn't allowed, and check for it in parsing/validation.

i.e. we'd force:

{.config}
aspect-ratio-small: 1
aspect-ratio-large: 2
{}

instead of

{.config}
{.aspect-ratio}
small: 1
large: 2
{}
{}

I don't have a strong opinion on this, but maybe it makes it a little easier to work with and a little harder to write? (Though, the most common archieML breakage we encounter is authors mistakenly missing/closing brackets, so this might make that failure mode a little rarer)

Copy link
Member

@ikesau ikesau left a comment

Choose a reason for hiding this comment

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

All the Archie stuff looks good! Will QA this tomorrow before full approval 👍

Copy link
Member

@ikesau ikesau left a comment

Choose a reason for hiding this comment

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

Really clean proof of concept!

I left a few thoughts on some smaller details, but the overall approach makes sense to me.

.with({ type: "bespoke-component" }, (block) => (
<BespokeComponent
className={getLayout(
`bespoke-component--${block.size}`,
Copy link
Member

Choose a reason for hiding this comment

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

What if we just rendered the container at 12 columns no matter what, and the viz itself took care of its sizing?

Doing it this way (with block.size) means that the logic for sizing a bespoke data viz will exist in at least 2 places:

  1. The archie wrapper component (adding a dependency on our site's CSS (unlikely to change, but technically possible)
  2. The bespoke viz rendering & CSS

Given that there's a config object that we can pass down (if we want a big and small version of the same viz) I feel like we might as well centralize?

Copy link
Member Author

Choose a reason for hiding this comment

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

Hm, I'm not sure how this will play along with putting this component into other, small components - whether it's an aside or side-by-side or anything else - but I'll keep it in mind when thinking about grid-aware sizing.

Comment on lines +74 to +77
// Create a container div inside the shadow root for the component to render into
const mountContainer = document.createElement("div")
mountContainer.className = "bespoke-container"
shadowRoot!.appendChild(mountContainer)
Copy link
Member

Choose a reason for hiding this comment

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

Maybe you could still fix this, but it seems like... not a big deal to me, given that block.config and variant won't change, due to how we only ever hydrate the page once.

Comment on lines +133 to +134
position: "relative",
minHeight: 300,
Copy link
Member

Choose a reason for hiding this comment

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

So this is where we could implement the aspect ratio system we were talking about.

If we go with the "centralize sizing information in one place" idea that I mentioned in the other comment, does that mean we'll also need a max-width attribute for each variant? 🤔

{.config}
desktop-aspect-ratio: 1.618
max-width: 900px
mobile-aspect-ratio: 1
// we'd probably want mobile max width to always be 100%
{}

Or if we want it to be more consistent, use a size enum.

Comment on lines +26 to +32
test: {
scriptUrl:
"https://owid-public.owid.io/marcel-bespoke-data-viz-02-2026/poverty-plots/income-plots.mjs",
cssUrls: [
"https://owid-public.owid.io/marcel-bespoke-data-viz-02-2026/poverty-plots/income-plots.css",
],
},
Copy link
Member

Choose a reason for hiding this comment

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

Right so we still need to figure out the way we write and generate these bundles. I guess for starters it doesn't need to be too systematized.

@github-actions
Copy link

github-actions bot commented Mar 6, 2026

This PR has had no activity within the last two weeks. It is considered stale and will be closed in 3 days if no further activity is detected.

@github-actions github-actions bot added the stale label Mar 6, 2026
@github-actions github-actions bot closed this Mar 9, 2026
@marcelgerber marcelgerber reopened this Mar 9, 2026
@marcelgerber marcelgerber force-pushed the gdocs-bespoke-component branch from c77eeb7 to 95ed30d Compare March 9, 2026 13:59
@marcelgerber
Copy link
Member Author

@codex review

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f5d185ae4d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +124 to +125
if (error) {
return <BespokeError className={className} message={error} />

Choose a reason for hiding this comment

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

P2 Badge Reset error state before rendering fallback-only branch

After any load failure, setError(...) causes the component to render only BespokeError, which removes the containerRef node. Because the effect exits early on if (!container) return, the same mounted instance cannot hydrate again on later prop updates (e.g. live-preview edits or retry-worthy transient CDN failures), so the block stays permanently broken until a full remount. Clearing error before retry/hydration (and keeping a mount container available) avoids this dead-end state.

Useful? React with 👍 / 👎.

Comment on lines +74 to +77
const mountContainer = document.createElement("div")
mountContainer.className = "bespoke-container"
shadowRoot!.appendChild(mountContainer)

Choose a reason for hiding this comment

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

P2 Badge Remove stale shadow nodes when rehydrating bespoke block

Each effect run appends a new bespoke-container into the shadow root, but cleanup only calls an optional module-provided disposer and never removes previously appended DOM/styles. Since mount is explicitly allowed to return void, updates to bundle, variant, or config on the same component instance can accumulate old mounts and leaked styles, leading to duplicated or stale UI. Explicitly clearing/replacing injected shadow-root nodes during rehydration would prevent this.

Useful? React with 👍 / 👎.

@marcelgerber marcelgerber merged commit f2a096f into master Mar 11, 2026
25 of 27 checks passed
@marcelgerber marcelgerber deleted the gdocs-bespoke-component branch March 11, 2026 12:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants