diff --git a/.changeset/bright-users-update.md b/.changeset/bright-users-update.md new file mode 100644 index 00000000..fbed88e3 --- /dev/null +++ b/.changeset/bright-users-update.md @@ -0,0 +1,7 @@ +--- +"@playhtml/common": minor +"playhtml": minor +"@playhtml/react": minor +--- + +Add the element `live`, `users`, `setLive`, and `update` APIs so one renderer can combine shared state with current per-user values and identity. Existing awareness and `updateElement` names remain available as deprecated compatibility aliases. diff --git a/apps/docs/src/content/docs/advanced/dynamic-elements.mdx b/apps/docs/src/content/docs/advanced/dynamic-elements.mdx index 3486056d..1328d263 100644 --- a/apps/docs/src/content/docs/advanced/dynamic-elements.mdx +++ b/apps/docs/src/content/docs/advanced/dynamic-elements.mdx @@ -46,7 +46,7 @@ document.body.appendChild(note); playhtml.register(note, { defaultData: { text: "" }, - updateElement: ({ element, data }) => { + update: ({ element, data }) => { element.textContent = data.text; }, }); diff --git a/apps/docs/src/content/docs/advanced/merging-data.md b/apps/docs/src/content/docs/advanced/merging-data.md index 32eb721b..a054923b 100644 --- a/apps/docs/src/content/docs/advanced/merging-data.md +++ b/apps/docs/src/content/docs/advanced/merging-data.md @@ -139,7 +139,7 @@ setData((draft) => { ## Reactive callbacks -The riskiest conflict is a callback that reads shared data, writes shared data, and re-runs when that same data changes. React effects, subscriptions, and vanilla `updateElement` callbacks can all do this. +The riskiest conflict is a callback that reads shared data, writes shared data, and re-runs when that same data changes. React effects, subscriptions, and vanilla `update` callbacks can all do this. ```tsx useEffect(() => { diff --git a/apps/docs/src/content/docs/advanced/mirror-playground.mdx b/apps/docs/src/content/docs/advanced/mirror-playground.mdx index 095e963c..5673223c 100644 --- a/apps/docs/src/content/docs/advanced/mirror-playground.mdx +++ b/apps/docs/src/content/docs/advanced/mirror-playground.mdx @@ -19,7 +19,7 @@ This page hosts the common edge cases, one section each. Open it in two browser - direct child additions, removals, and reorders on the mirrored element itself; - form state inside the mirrored element when inputs fire input/change events; - contenteditable changes when the browser reports them through input events; -- hover and focus awareness through `data-playhtml-hover` and `data-playhtml-focus`. +- live hover and focus state through `data-playhtml-hover` and `data-playhtml-focus`. `can-mirror` does not update: diff --git a/apps/docs/src/content/docs/capabilities.mdx b/apps/docs/src/content/docs/capabilities.mdx index 08f87f85..d9b29dbb 100644 --- a/apps/docs/src/content/docs/capabilities.mdx +++ b/apps/docs/src/content/docs/capabilities.mdx @@ -311,7 +311,7 @@ import { CanHoverElement } from "@playhtml/react"; -If you want the hover effect to reflect *who* is hovering (e.g. tint the element with each viewer's cursor color) rather than a plain on/off, read the hover roster off element awareness. Awareness is presence scoped to one element, so it clears when readers leave and does not persist: +If you want the hover effect to reflect *who* is hovering (for example, tint the element with each viewer's color) rather than a plain on/off, render the element's `users`. Live user data clears when readers leave and does not persist: ```tsx import { CanPlayElement } from "@playhtml/react"; @@ -321,12 +321,12 @@ import { TagType } from "playhtml"; tagInfo={[TagType.CanHover]} id="hover-pad" defaultData={{}} - myDefaultAwareness={"#3b82f6"} + live={{ hovering: false }} > - {({ awareness }) => ( + {({ users }) => (
user.color).join(", ")})`, }} > hover me @@ -409,7 +409,7 @@ Full treatment with live demos lives on **[Custom elements → can-mirror](/docs ## can-play -Build your own capability: you define a shared `data` shape and how the element renders from it (an imperative `updateElement`, or the newer reactive `view`). You control how the data drives the element and which parts persist. Use it for custom counters, guestbooks, chat, games, reactions, per-user state, and event broadcasts — anything the built-ins don't cover. +Build your own capability: you define a shared `data` shape and how the element renders from it (an imperative `update`, or the newer reactive `view`). You control how the data drives the element and which parts persist. Use it for custom counters, guestbooks, chat, games, reactions, per-user state, and event broadcasts — anything the built-ins don't cover. Full guide with live demos: **[Custom elements](/docs/custom-elements/)**. Property reference: **[Element API](/docs/reference/element-api/)**. @@ -423,7 +423,7 @@ These capabilities are the building blocks. The best way to see them in concert EXPERIMENT · 04 Every color - A page where every visitor adds one color. can-play + element awareness + an ever-growing shared palette. + A page where every visitor adds one color. can-play + element users + an ever-growing shared palette. COMMUNITY · FRIDGE diff --git a/apps/docs/src/content/docs/concepts.md b/apps/docs/src/content/docs/concepts.md index 2171bdfc..91d7b84a 100644 --- a/apps/docs/src/content/docs/concepts.md +++ b/apps/docs/src/content/docs/concepts.md @@ -13,7 +13,7 @@ Once you can reach for the right one, everything else is just attribute names. - **Element data** (`defaultData` / [`can-play`](/docs/capabilities/)): persistent state scoped to a single DOM element. A toggle's on/off, a draggable's position, a shared count. Survives reload. See [data essentials](/docs/data/data-essentials/) for shape, updates, and cleanup. - **Page data** (`playhtml.createPageData`): persistent state keyed by a name, not tied to any element. A page-level counter, a shared prompt, an open vote. See [page-level data](/docs/data/page-data/). -- **Presence** (`playhtml.presence`, cursors, element awareness): ephemeral per-user state: "who's online", "who's typing", "where's my cursor". Element APIs call this same kind of state _awareness_ when it is scoped to one element. Disappears when users disconnect. See [presence](/docs/data/presence/) and [cursors](/docs/data/presence/cursors/). +- **Users** (`playhtml.users`, cursors, element `live` data): identity plus ephemeral per-user state: "who's online", "who's typing", "where's my cursor". Element callbacks join each user's identity with their element-specific value in `users`. Disappears when users disconnect. See [presence](/docs/data/presence/) and [cursors](/docs/data/presence/cursors/). - **Events** (`playhtml.dispatchPlayEvent`): one-off broadcasts with no persisted state. Confetti, chimes, notifications. See [events](/docs/data/events/). Not sure which one you want? The [decision table on data essentials](/docs/data/data-essentials/#when-to-use-which-primitive) lays out the tradeoffs side by side. diff --git a/apps/docs/src/content/docs/custom-elements.mdx b/apps/docs/src/content/docs/custom-elements.mdx index 8587f1d5..4fbc790d 100644 --- a/apps/docs/src/content/docs/custom-elements.mdx +++ b/apps/docs/src/content/docs/custom-elements.mdx @@ -1,6 +1,6 @@ --- title: "Custom elements" -description: "Build custom collaborative elements with can-mirror, register, updateElement, view, or React shared state." +description: "Build custom collaborative elements with can-mirror, register, update, view, or React shared state." sidebar: order: 5 --- @@ -116,12 +116,12 @@ If a mirrored child also has its own playhtml capability, keep giving it a stabl ### can-play -Register an element with its starting data, event handlers, and renderer in one initializer object. Pass the element itself when you already have it, or pass its id before it exists. Both `playhtml.register(element, initializer)` and `playhtml.register(id, initializer)` work before or after `playhtml.init()`. playhtml syncs the data and calls `updateElement` whenever it changes. +Register an element with its starting data, event handlers, and renderer in one initializer object. Pass the element itself when you already have it, or pass its id before it exists. Both `playhtml.register(element, initializer)` and `playhtml.register(id, initializer)` work before or after `playhtml.init()`. playhtml calls `update` whenever shared data or a user's live value changes. The [Element API reference](/docs/reference/element-api/#initializer) lists every initializer field and callback context. :::note[Existing property-based elements] -Direct assignments such as `element.defaultData = …` and `element.updateElement = …` remain supported for compatibility. Use `register` for new vanilla elements. +Direct assignments such as `element.defaultData = …` and `element.updateElement = …` remain supported for compatibility. Use `register` with `update` for new vanilla elements. ::: Most capabilities can share the same element. Each keeps its own data and behavior, so a registered element can also carry `can-move`. Watch out for style conflicts: two capabilities can still fight if they write the same CSS property. For example, `can-move` and `can-spin` both update `transform`, so the last update wins instead of combining translate and rotate. @@ -141,7 +141,7 @@ Most capabilities can share the same element. Each keeps its own data and behavi data.count += 1; }); }, - updateElement: ({ element, data }) => { + update: ({ element, data }) => { element.textContent = String(data.count); }, }); @@ -169,7 +169,7 @@ Most capabilities can share the same element. Each keeps its own data and behavi #### State to style -`updateElement` runs on every change, so the simplest pattern is "read `data`, set a class or style." +`update` runs on every change, so the simplest pattern is "read `data`, set a class or style." ```html @@ -184,7 +184,7 @@ Most capabilities can share the same element. Each keeps its own data and behavi data.on = !data.on; }); }, - updateElement: ({ element, data }) => { + update: ({ element, data }) => { element.classList.toggle("on", data.on); element.textContent = data.on ? "on" : "off"; }, @@ -204,9 +204,9 @@ Most capabilities can share the same element. Each keeps its own data and behavi
-#### Presence and per-user state +#### Users and live per-user state -`myDefaultAwareness` seeds a per-user ephemeral value; `setMyAwareness` broadcasts it; `updateElementAwareness` renders everyone's. Awareness does **not** persist — it's "who's here right now." +`live` is your value for this element. It does not persist. `setLive` changes it, and every user's current value appears in `users` beside their identity. The same `update` function renders shared `data` and live user changes. ```html
@@ -215,13 +215,14 @@ Most capabilities can share the same element. Each keeps its own data and behavi import { playhtml } from "playhtml"; playhtml.register("online", { - myDefaultAwareness: "#2563eb", - updateElementAwareness: ({ element, awareness }) => { + live: { status: "here" }, + update: ({ element, users }) => { element.replaceChildren( - ...awareness.map((color) => { + ...users.map(({ user }) => { const dot = document.createElement("span"); dot.className = "dot"; - dot.style.background = color; + dot.style.background = user.color; + dot.title = user.name ?? "Anonymous"; return dot; }), ); @@ -250,7 +251,7 @@ Put `resetShortcut` in the initializer so shift-clicking (or ctrl/alt/meta) rese playhtml.register("counter", { defaultData: { count: 0 }, resetShortcut: "shiftKey", - updateElement: ({ element, data }) => { + update: ({ element, data }) => { element.textContent = String(data.count); }, }); @@ -261,19 +262,19 @@ playhtml.register("counter", { `register` can run before the element exists and binds it when it appears. For dynamically added built-in or defined capabilities, call `playhtml.setupPlayElement(element)` after insertion. Use [`selector-id`](/docs/advanced/dynamic-elements/) for many-of-a-kind elements. :::note[Lists and data-driven UIs get verbose here] -With `updateElement` you hand-build and diff the DOM yourself, which is painful for lists, chat, and anything computed from a collection. That's exactly what the [reactive view API](#reactive-view-api-experimental) below is for. +With `update` you hand-build and diff the DOM yourself, which is painful for lists, chat, and anything computed from a collection. That's exactly what the [reactive view API](#reactive-view-api-experimental) below is for. ::: ### Reactive view API (experimental) -Instead of hand-writing DOM updates in `updateElement`, you can register a **`view`**: a pure function from state to a [lit-html](https://lit.dev/docs/libraries/standalone-templates/) template. playhtml patches only what changed when the data updates. It's the vanilla equivalent of what React's `withSharedState` already gives you. +Instead of hand-writing DOM updates in `update`, you can register a **`view`**: a pure function from state to a [lit-html](https://lit.dev/docs/libraries/standalone-templates/) template. playhtml patches only what changed when the data updates. It's the vanilla equivalent of what React's `withSharedState` already gives you. ```ts import { playhtml, html, svg, repeat, classMap, styleMap, nothing } from "playhtml"; @@ -302,7 +303,7 @@ The element is an empty mount point; everything you see is rendered from state. ``` -`register` returns a **handle** (`getElement`, `getData`, `setData`, `setLocalData`, `setMyAwareness`, `requestUpdate`, `unregister`) for reads/writes from outside the view. +`register` returns a **handle** (`getElement`, `getData`, `setData`, `setLocalData`, `setLive`, `requestUpdate`, `unregister`) for reads/writes from outside the view. #### Local UI state @@ -323,7 +324,7 @@ playhtml.register("panel", { #### Lists -This is where the view API earns its keep — `repeat(items, keyFn, template)` is lit-html's keyed list, versus hand-diffing DOM in `updateElement`. **Key by a stable unique id** (`crypto.randomUUID()`), never an index or timestamp. +This is where the view API earns its keep — `repeat(items, keyFn, template)` is lit-html's keyed list, versus hand-diffing DOM in `update`. **Key by a stable unique id** (`crypto.randomUUID()`), never an index or timestamp. ```js const guestbook = playhtml.register("guestbook", { @@ -443,14 +444,14 @@ playhtml.register("chats", { #### Rules and gotchas (view API) -- **Renders must be pure.** Don't call `setData` / `setLocalData` / `setMyAwareness` while rendering — it's an infinite re-render loop. playhtml rejects such writes with a console error. Drive writes from `@event` handlers or `onMount`. +- **Renders must be pure.** Don't call `setData` / `setLocalData` / `setLive` while rendering — it's an infinite re-render loop. playhtml rejects such writes with a console error. Drive writes from `@event` handlers or `onMount`. - **Key lists by a stable id**, never index or timestamp. - **Security:** interpolated text and attribute values are auto-escaped (and `unsafeHTML` is deliberately not exported). Two sharp edges remain about a binding's *content*: `style` from a user-controlled string is CSS-injectable (prefer `styleMap`), and `href`/`src` from user data isn't URL-sanitized (validate the scheme). - **Changing the shape of already-live data** needs a migration or a new field name — see [Data essentials](/docs/data/data-essentials/). ## React -In React there's a single, declarative path: **`withSharedState`**. It wraps a component, owns a piece of shared `data`, and hands the component `data` + `setData`. You render JSX from `data`; playhtml syncs it and re-renders on every change, local or remote. There's no `updateElement` to hand-write and no separate view API — JSX _is_ the declarative view. +In React there's a single, declarative path: **`withSharedState`**. It wraps a component and hands it shared `data`, element `live` data, and `users`. JSX re-renders on every data or user change. There's no imperative `update` to hand-write and no separate view API — JSX _is_ the declarative view. ### A click counter @@ -537,17 +538,17 @@ export const Guestbook = withSharedState( [▶ Live demo ↑](#play-guestbook) -### Presence and per-user state +### Users and live per-user state -`myDefaultAwareness` seeds a per-user ephemeral value; `setMyAwareness` broadcasts it; the `awareness` prop renders everyone's. Awareness does **not** persist — it's "who's here right now." +`live` seeds your ephemeral value; `setLive` broadcasts it; `users` renders every current user with their identity and live value. ```tsx export const Online = withSharedState( - { defaultData: {}, myDefaultAwareness: "#2563eb" }, - ({ awareness }) => ( + { defaultData: {}, live: { ready: false } }, + ({ users }) => (
- {awareness.map((color, i) => ( - + {users.map(({ user }) => ( + ))}
), diff --git a/apps/docs/src/content/docs/data/data-essentials.md b/apps/docs/src/content/docs/data/data-essentials.md index 3a7be2f4..17d68c3a 100644 --- a/apps/docs/src/content/docs/data/data-essentials.md +++ b/apps/docs/src/content/docs/data/data-essentials.md @@ -18,7 +18,7 @@ playhtml has four primitives for moving state between readers. Pick by **lifetim | A toggle, position, count, or any state tied to one element | [Element data](/docs/data/data-essentials/) (`defaultData` / `can-play`) | Yes | One element | | A page-wide counter, prompt, or vote not tied to a DOM node | [Page data](/docs/data/page-data/) (`playhtml.createPageData`) | Yes | One page | | "Who is connected right now?" / "How many readers?" | [Presence](/docs/data/presence/) (`playhtml.presence.getPresences()`) | No | Per-user | -| "Who's typing in this input?" / live status | [Custom presence channel](/docs/data/presence/#custom-channels) or element awareness | No | Per-user | +| "Who's typing in this input?" / live status | [Custom presence channel](/docs/data/presence/#custom-channels) or element live data | No | Per-user | | "Where is everyone's cursor?" | [Cursors](/docs/data/presence/cursors/) | No | Per-user | | Confetti burst, chime, notification | [Events](/docs/data/events/) (`dispatchPlayEvent`) | No (fires once) | Broadcast | | "How many people reacted to this post?" | Element data (a `count` field) | Yes | One element | @@ -112,14 +112,14 @@ defaultData: { ### 2. Don't store computed or derived values -Compute them when you render — in `updateElement` or `view` (vanilla), or the render function (React). Storing them means they go stale whenever the source changes and you forget. +Compute them when you render — in `update` or `view` (vanilla), or the render function (React). Storing them means they go stale whenever the source changes and you forget. ```js // Good — derive at render time. Either vanilla form works: -// imperative — updateElement +// imperative — update defaultData: { count: 5 } -updateElement: ({ element, data }) => { +update: ({ element, data }) => { element.textContent = `${data.count} (${data.count % 2 === 0 ? "even" : "odd"})`; } @@ -142,7 +142,7 @@ playhtml gives you three places to put state. Use the one that matches the lifet | Type | Survives reload | Use for | |---|---|---| | Persistent (`defaultData`) | Yes | Positions, counts, messages, settings, toggles | -| Presence, including element awareness | No | Who's online, typing indicators, colors, per-user cursor data | +| Presence, including element live data | No | Who's online, typing indicators, colors, per-user cursor data | | Events | No (fire once) | Confetti bursts, notifications, chimes | If someone refreshes the page and expects the state to still be there, it's persistent data. If a new reader opening the page for the first time should _not_ see a historical replay, it's presence or an event. @@ -159,7 +159,7 @@ playhtml.register("draggable", { onDrag: (event, { setData }) => { setData({ x: event.clientX, y: event.clientY }); }, - updateElement: ({ element, data }) => { + update: ({ element, data }) => { element.style.translate = `${data.x}px ${data.y}px`; }, }); @@ -272,7 +272,7 @@ Two reinforcing rules at work here: If you genuinely need an array (order matters and there's no natural key), then you must both (a) read via a ref as above and (b) make the write converge (dedupe by id into a `Map` and write the deduped result), but a keyed map is almost always the better shape. -The same rule applies to vanilla `updateElement`: never call `setData` from inside `updateElement` (which runs on every data change) without a guard that provably converges. When in doubt, write only from explicit user events, not from reactive callbacks. +The same rule applies to vanilla `update`: never call `setData` from inside `update` (which runs on every data change) without a guard that provably converges. When in doubt, write only from explicit user events, not from reactive callbacks. ### 8. Use `localStorage` for per-user preferences diff --git a/apps/docs/src/content/docs/data/presence/index.mdx b/apps/docs/src/content/docs/data/presence/index.mdx index 71756e8d..33b587fd 100644 --- a/apps/docs/src/content/docs/data/presence/index.mdx +++ b/apps/docs/src/content/docs/data/presence/index.mdx @@ -10,11 +10,11 @@ import { OnlineIndicatorDemo } from "@/components/react/data-demos/OnlineIndicat Presence is used for anything that is real-time and ephemeral (meaning it doesn't stick around after you leave). For example, cursors only matter when you are actually on the page. When you leave, they are gone and there's no record of them. -For the full API surface (`PlayerIdentity`, `PresenceAPI`, element awareness, cursor types), see the [Presence & identity reference](/docs/reference/presence/). +For the full API surface (`PlayerIdentity`, `PresenceAPI`, element users, cursor types), see the [Presence & identity reference](/docs/reference/presence/). When you want things to stay after refreshing, use [persistent data](/docs/data/data-essentials/) and use [events](/docs/data/events/) for real-time events that don't rely on updating data. -Element APIs use the word **awareness** for presence scoped to one element. `myDefaultAwareness`, `awareness`, and `setMyAwareness` are still ephemeral per-user state; they just belong to a specific element instead of the page-level `playhtml.presence` object. +Element APIs call presence scoped to one element **live user data**. Configure your value with `live`, change it with `setLive`, and render everyone from `users`. It belongs to one element instead of the page-level `playhtml.presence` object. ## The unified API @@ -118,11 +118,13 @@ setMyPresence(null); There's no partial/merge update for a channel. When you call `setMyPresence`, you overwrite that channel's value for your user. -## Element awareness +## Element users -Use page-level presence when the signal belongs to the room: online status, cursor position, lobby readiness, or a typing indicator that several parts of the page may read. Use element awareness when the signal only matters for one playhtml element, like "who has joined this widget" or "which color is this reader contributing here". +Use page-level presence when the signal belongs to the room: online status, cursor position, lobby readiness, or a typing indicator that several parts of the page may read. Use element `live` values when the signal only matters for one playhtml element, like "who has joined this widget" or "which choice is this reader previewing here." -In vanilla HTML, put element awareness in the initializer passed to `register`: +Each item in `users` has two parts: `user` is the person's identity (`pid`, `name`, `color`, and `isMe`), while `live` is the value they published for this element. + +In vanilla HTML, add `live` to the initializer passed to `register`: ```html @@ -131,13 +133,13 @@ In vanilla HTML, put element awareness in the initializer passed to `register`: import { playhtml } from "playhtml"; playhtml.register("presence-count", { - myDefaultAwareness: { color: "#3b82f6" }, - onClick: (_event, { setMyAwareness }) => { - setMyAwareness({ color: "#f97316" }); + live: { ready: false }, + onClick: (_event, { live, setLive }) => { + setLive({ ready: !live.ready }); }, - updateElementAwareness: ({ element, awareness }) => { - const label = awareness.length === 1 ? "reader" : "readers"; - element.textContent = `${awareness.length} ${label} here`; + update: ({ element, users }) => { + const ready = users.filter(({ live }) => live.ready).length; + element.textContent = `${ready} of ${users.length} ready`; }, }); @@ -148,19 +150,21 @@ In vanilla HTML, put element awareness in the initializer passed to `register`: In React, the same fields are available from `withSharedState` and `` render props: ```tsx - - {({ awareness, setMyAwareness }) => { - const label = awareness.length === 1 ? "reader" : "readers"; + + {({ live, users, setLive }) => { + const ready = users.filter((entry) => entry.live.ready).length; return ( - ); }} ``` -Awareness has the same lifetime as presence: it clears when the user leaves, and a late visitor does not replay it. +Element live data has the same lifetime as presence: it clears when the user leaves, and a late visitor does not replay it. + +Tabs in the same browser share one identity and appear as one user. To test with two people, open the second copy in an incognito window or another browser profile. ## Isolated presence rooms @@ -210,4 +214,4 @@ Each dot is one reader; the yellow-glowing dot is **you**. Pick a color and watc Presence is for the live sense of other people on the page. If you find yourself reaching for `localStorage` or a refresh-survivor, you want data, not presence. -Not sure which primitive fits? The full decision table, covering element data, page data, presence, element awareness, cursors, and events, is on [data essentials](/docs/data/data-essentials/#when-to-use-which-primitive). +Not sure which primitive fits? The full decision table, covering element data, page data, presence, element users, cursors, and events, is on [data essentials](/docs/data/data-essentials/#when-to-use-which-primitive). diff --git a/apps/docs/src/content/docs/examples/matter-physics.mdx b/apps/docs/src/content/docs/examples/matter-physics.mdx index 2d815504..81d92a46 100644 --- a/apps/docs/src/content/docs/examples/matter-physics.mdx +++ b/apps/docs/src/content/docs/examples/matter-physics.mdx @@ -34,7 +34,7 @@ The example instead: - clamps positions to the visible world before publishing; - writes body fields in place under their stable keys. -All shared writes start from a user action or from the one browser currently controlling the simulation. `updateElement` only reads shared data and updates local targets. +All shared writes start from a user action or from the one browser currently controlling the simulation. `update` only reads shared data and updates local targets. ## Test it in two windows diff --git a/apps/docs/src/content/docs/examples/shared-counter.mdx b/apps/docs/src/content/docs/examples/shared-counter.mdx index dc6ae6bc..b3358ce0 100644 --- a/apps/docs/src/content/docs/examples/shared-counter.mdx +++ b/apps/docs/src/content/docs/examples/shared-counter.mdx @@ -29,7 +29,7 @@ playhtml.register("ph-docs-counter", { draft.count += 1; }); }, - updateElement: ({ element, data }) => { + update: ({ element, data }) => { element.querySelector("[data-count]").textContent = String(data.count); }, }); @@ -40,7 +40,7 @@ remote update. ## Render the current count -`updateElement` in the registered initializer reads shared data without writing +`update` in the registered initializer reads shared data without writing it. playhtml calls it after initialization and whenever the shared count changes. See [Data essentials](/docs/data/data-essentials/) for mutator and replacement diff --git a/apps/docs/src/content/docs/integrations/building-with-ai.md b/apps/docs/src/content/docs/integrations/building-with-ai.md index 2233349b..f5f6157d 100644 --- a/apps/docs/src/content/docs/integrations/building-with-ai.md +++ b/apps/docs/src/content/docs/integrations/building-with-ai.md @@ -57,7 +57,7 @@ CRITICAL REQUIREMENTS: SETUP — Vanilla HTML (custom element with register + view): Put an empty mount point in the HTML, then register an initializer. `register` can be called before OR after `playhtml.init()` and before OR after the element -exists. Use `updateElement` for imperative DOM updates or the experimental +exists. Use `update` for imperative DOM updates or the experimental `view` renderer for lit-html templates.
@@ -92,7 +92,7 @@ SETUP — React: DATA TYPES (choose the right one): 1. Persistent data (defaultData): State that syncs and persists (position, count, messages, etc.) 2. Presence: Temporary per-user state (which users are online, their colors, cursor positions) -3. Element awareness: Presence scoped to one element (who is hovering this card, this user's color in this widget) +3. Element users: Identity plus live data scoped to one element (who is hovering this card, this user's choice in this widget) 4. Events: One-time triggers (confetti, notifications, animations) — use dispatchPlayEvent/registerPlayEventListener KEY APIs: @@ -101,21 +101,21 @@ Vanilla HTML (register): - playhtml.register(elementOrId, init) → handle // Bind one element by DOM node or id - playhtml.define(name, init) // Reusable capability for every [name] element - init.defaultData = { ... } // Initial state (REQUIRED) -- init.updateElement = ({ element, data }) => { ... } // Supported imperative renderer +- init.update = ({ element, data, live, users }) => { ... } // Supported imperative renderer - init.view = ({ data, setData }) => html`...` // Experimental declarative renderer - init.onMount = (ctx) => { ...; return cleanup } // Setup loops/listeners; return a cleanup - init.resetShortcut = "shiftKey" // Keyboard reset - ctx.setData(value | (draft) => { ... }) // Write shared state - ctx.localData / ctx.setLocalData(...) // Per-user, un-synced UI state -- ctx.awareness / ctx.setMyAwareness(...) // Presence +- ctx.live / ctx.users / ctx.setLive(...) // Element users - ctx.requestUpdate() // Repaint clock-driven views (timers) -Use exactly one renderer: `updateElement` or `view`. Do not assign initializer +Use exactly one renderer: `update` or `view`. Do not assign initializer fields directly to the DOM element in new code. React (withSharedState): - withSharedState({ defaultData: {...} }, ({ data, setData, ref }) => JSX) -- For element awareness: { myDefaultAwareness: value } in config, use setMyAwareness +- For element users: { live: value } in config, use setLive and render users - For events: usePlayContext() → { registerPlayEventListener, dispatchPlayEvent } - For cursors in React: usePlayContext() → { cursors, configureCursors, getMyPlayerIdentity } @@ -160,7 +160,7 @@ DATA PERFORMANCE TIPS: - Keep data shapes simple and flat (avoid deep nesting) - Don't store computed/derived values — calculate them in the view / render function - Use events for ephemeral actions (confetti, notifications), not persistent data -- Use presence or element awareness for temporary per-user state, not defaultData +- Use presence or element live data for temporary per-user state, not defaultData - Don't update data on high-frequency events (mousemove, scroll) — debounce - For growing lists (messages, history), consider limiting size or implementing cleanup - Store only what needs to sync — use component state for UI-only state @@ -168,7 +168,7 @@ DATA PERFORMANCE TIPS: INSTRUCTIONS: - If the behavior description is unclear, ASK clarifying questions before implementing -- Choose the right data type (persistent data, presence or element awareness, or events) +- Choose the right data type (persistent data, presence or element users, or events) - Provide complete, working code - Include all necessary imports and setup diff --git a/apps/docs/src/content/docs/reference/capabilities.md b/apps/docs/src/content/docs/reference/capabilities.md index 64103a6c..9301512e 100644 --- a/apps/docs/src/content/docs/reference/capabilities.md +++ b/apps/docs/src/content/docs/reference/capabilities.md @@ -291,7 +291,7 @@ Surfaces hover state from any visitor on the page. This is **presence-only** — ### Data -`can-hover` stores no persistent shared data. Hover state is transmitted as **element awareness**: an ephemeral, per-connection signal scoped to this element. +`can-hover` stores no persistent shared data. Hover state is transmitted as ephemeral live user data scoped to this element. **Awareness shape (per visitor):** `{ hover: boolean }` @@ -315,7 +315,7 @@ Style the hover effect by targeting this attribute: ### Reset -No reset shortcut. Hover awareness clears automatically when each visitor stops hovering or disconnects. +No reset shortcut. Live hover state clears automatically when each visitor stops hovering or disconnects. --- @@ -336,7 +336,7 @@ Syncs an element's **attributes**, **direct child list**, and **form / contented | Contenteditable content | Yes, via `input` and `change` events | | Descendant mutations (arbitrary depth) | **No** — `can-mirror` only observes the element itself, not its subtree | -Ephemeral attributes (`data-playhtml-hover`, `data-playhtml-focus`) are not stored in the shared snapshot. They are driven by awareness instead. +Ephemeral attributes (`data-playhtml-hover`, `data-playhtml-focus`) are not stored in the shared snapshot. They are driven by live user state instead. ### Data @@ -356,7 +356,7 @@ Ephemeral attributes (`data-playhtml-hover`, `data-playhtml-focus`) are not stor ### Presence: `data-playhtml-hover` and `data-playhtml-focus` -`can-mirror` also tracks focus state via awareness. When any visitor focuses a descendant of the element, `data-playhtml-focus` is set on the element for all visitors. Style accordingly: +`can-mirror` also tracks live focus state. When any visitor focuses a descendant of the element, `data-playhtml-focus` is set on the element for all visitors. Style accordingly: ```css #my-editor[data-playhtml-focus] { @@ -372,7 +372,7 @@ No reset shortcut. ## `can-play` -Define your own shared data and how the element renders (`updateElement` or experimental `view`). Use it for counters, guestbooks, games, and anything the built-ins do not cover. +Define your own shared data and how the element renders (`update` or experimental `view`). Use it for counters, guestbooks, games, and anything the built-ins do not cover. **Guide:** [Custom elements](/docs/custom-elements/) **Reference:** [Element API](/docs/reference/element-api/) · [Registration API](/docs/reference/view-api/) diff --git a/apps/docs/src/content/docs/reference/element-api.md b/apps/docs/src/content/docs/reference/element-api.md index 01736d0f..465b56a6 100644 --- a/apps/docs/src/content/docs/reference/element-api.md +++ b/apps/docs/src/content/docs/reference/element-api.md @@ -1,6 +1,6 @@ --- title: "Element initializer API" -description: "Configure data defaults, renderers, event handlers, awareness, and lifecycle callbacks." +description: "Configure data defaults, renderers, event handlers, live users, and lifecycle callbacks." sidebar: order: 3 --- @@ -11,7 +11,7 @@ The `ElementInitializer` configures a custom collaborative element. Use the same 2. **`playhtml.define(name, initializer)`** for a reusable capability 3. **`extraCapabilities`** in `playhtml.init()` for capabilities declared during initialization -The initializer has three state buckets: shared **`data`**, per-user **`localData`**, and ephemeral **`awareness`**. +The initializer has three state buckets: shared **`data`**, per-tab **`localData`**, and ephemeral per-user **`live`** data. For usage examples, see [Custom elements](/docs/custom-elements/). @@ -21,26 +21,26 @@ Directly assigning initializer fields to an element remains supported for compat ### Callback context (`ctx`) -Passed to `updateElement`, `view`, `onClick`, `onDrag`, and `onDragStart`: +Passed to `update`, `view`, `onClick`, `onDrag`, and `onDragStart`: ```js { data, // shared synced state (read-only snapshot) localData, // per-user, per-tab; not synced - awareness, // array of every user's awareness value for this element - awarenessByStableId, // Map + live, // your ephemeral value for this element + users, // [{ user: { pid, name, color, isMe }, live }] element, // the HTMLElement setData, // (next) mutator fn or replacement object setLocalData, // (next) mutator fn or replacement; re-renders view - setMyAwareness, // (next) your ephemeral awareness value + setLive, // (next) your ephemeral value for this element requestUpdate, // () re-run view now; no-op without view } ``` -`updateElementAwareness` receives the same fields, plus **`myAwareness`** (your own awareness value). +The deprecated `awareness`, `awarenessByStableId`, `myAwareness`, and `setMyAwareness` fields remain available as compatibility aliases. -Do not call `setData`, `setLocalData`, or `setMyAwareness` during a `view` render — playhtml logs an error and ignores the write. `setData` merge rules: [Data essentials](/docs/data/data-essentials/). +Do not call `setData`, `setLocalData`, or `setLive` during a `view` render — playhtml logs an error and ignores the write. `setData` merge rules: [Data essentials](/docs/data/data-essentials/). ### `onMount` context @@ -50,12 +50,13 @@ Do not call `setData`, `setLocalData`, or `setMyAwareness` during a `view` rende { getData, // () => current shared data getLocalData, // () => current local data - getAwareness, // () => awareness array + getLive, // () => your current live value + getUsers, // () => current element users getElement, // () => the HTMLElement setData, // same setters as ctx setLocalData, - setMyAwareness, + setLive, requestUpdate, } ``` @@ -68,34 +69,29 @@ Everything you can put in an initializer for `register`, `define`, or `extraCapa { // Starting shared state for elements that render from shared data. Must be // an object (or a function that returns one) — not a bare number or string. - // Synced across the room. Must be paired with updateElement or view. + // Synced across the room. Must be paired with update or view. defaultData: { count: 0 }, // defaultData: (element) => ({ color: element.dataset.color }), // Per-user, per-tab state. Never synced. Drag anchors, drafts, UI flags. defaultLocalData: undefined, - // Your starting awareness value for this element. Ephemeral — clears on - // disconnect. Pair with updateElementAwareness. - myDefaultAwareness: undefined, + // Your starting live value for this element. Clears when you disconnect. + live: undefined, - // --- data update path: provide updateElement OR view, not both --- + // --- render path: provide update OR view, not both --- - updateElement(ctx) { - // ctx — see Callback context above. Write the DOM from ctx.data. + update(ctx) { + // ctx — see Callback context above. Write the DOM from ctx.data and ctx.users. // Do not call ctx.setData here — it loops. }, view(ctx) { // ctx — see Callback context above. Return a lit-html template. - // Mutually exclusive with updateElement, onClick, onDrag, onDragStart. + // Mutually exclusive with update, onClick, onDrag, onDragStart. // Drive ctx.setData from @click handlers, not during render. }, - updateElementAwareness(ctx) { - // ctx — Callback context + myAwareness. Pair with myDefaultAwareness. - }, - // --- event handlers (ignored when using view) --- onClick(e, ctx) {}, // e: MouseEvent; ctx — Callback context @@ -150,28 +146,29 @@ const initializer = { --- -## `myDefaultAwareness` +## `live` -Your starting awareness value for this element. Awareness is ephemeral — it clears when you disconnect. Other clients read it through the `awareness` array in callbacks. +Your starting ephemeral value for this element. It clears when you disconnect. Other clients receive it in `users`, joined with your identity. ```js const initializer = { - myDefaultAwareness: "#2563eb", + live: { ready: false }, }; ``` --- -## `updateElement` +## `update` -Imperative update path. playhtml calls it on mount and whenever shared `data`, `localData`, or awareness changes (locally or from another tab). Write the DOM from `ctx.data`. +Imperative update path. playhtml calls it on mount and whenever shared `data` or an element user's `live` value changes. Write the DOM from `ctx.data`, `ctx.live`, and `ctx.users`. -Mutually exclusive with `view`. Do not call `setData` inside `updateElement` — that creates a write loop. See [Data essentials](/docs/data/data-essentials/) rule 7. +Mutually exclusive with `view`. Do not call `setData` inside `update` — that creates a write loop. See [Data essentials](/docs/data/data-essentials/) rule 7. ```js const initializer = { - updateElement: ({ element, data }) => { + update: ({ element, data, users }) => { element.textContent = String(data.count); + element.dataset.users = String(users.length); }, }; ``` @@ -182,25 +179,15 @@ const initializer = { **Experimental.** Declarative update path. Return a [lit-html](https://lit.dev/) template; playhtml patches the DOM when state changes. -Mutually exclusive with `updateElement`, `onClick`, `onDrag`, and `onDragStart` — put events in the template (`@click`, etc.). +Mutually exclusive with `update`, `onClick`, `onDrag`, and `onDragStart` — put events in the template (`@click`, etc.). See [Registration API](/docs/reference/view-api/) for `register`, `define`, helpers, and handle methods. --- -## `updateElementAwareness` - -Called when element awareness changes. Same context as `updateElement`, plus `myAwareness` (your own value). - -```js -const initializer = { - updateElementAwareness: ({ element, awareness }) => { - element.dataset.viewers = String(awareness.length); - }, -}; -``` +## Deprecated compatibility names -If the element also has a `view`, awareness changes re-render the view automatically. +`updateElement`, `myDefaultAwareness`, `updateElementAwareness`, `awareness`, `awarenessByStableId`, `myAwareness`, `setMyAwareness`, and `getAwareness` remain available for existing elements. New code should use `update`, `live`, `users`, `setLive`, `getLive`, and `getUsers`. Supplying both names from an alias pair, such as `update` and `updateElement`, is an error. --- @@ -317,7 +304,7 @@ Pass the initializer to `playhtml.register`. The element needs a stable, unique data.count += 1; }); }, - updateElement: ({ element, data }) => { + update: ({ element, data }) => { element.textContent = String(data.count); }, }); @@ -344,9 +331,9 @@ If existing code assigns those properties and calls `setupPlayElement(element)`, At registration time, playhtml checks: -- `defaultData` and `updateElement` / `view` are provided together -- `myDefaultAwareness`, when present, is paired with `updateElementAwareness` -- At least one update function exists: `updateElement`, `view`, or `updateElementAwareness` -- `register` / `define` throw if both `view` and `updateElement` are set, or if `view` is combined with `onClick` / `onDrag` / `onDragStart` +- `defaultData` and `update` / `view` are provided together +- `live`, when present, is paired with `update` / `view` +- At least one renderer exists: `update` or `view` +- `register` / `define` throw if both `view` and `update` are set, if both `update` and `updateElement` are set, or if `view` is combined with `onClick` / `onDrag` / `onDragStart` If validation fails, the element is skipped and a console error lists the missing or invalid pair. diff --git a/apps/docs/src/content/docs/reference/init-options.md b/apps/docs/src/content/docs/reference/init-options.md index 1c050673..77266b8f 100644 --- a/apps/docs/src/content/docs/reference/init-options.md +++ b/apps/docs/src/content/docs/reference/init-options.md @@ -94,14 +94,14 @@ You can also register events imperatively later with `playhtml.registerPlayEvent Ship your own `can-*` capability alongside the built-ins. Most authors never need this; use `can-play` on individual elements first. Reach for `extraCapabilities` when you want a reusable `can-mything` attribute. See [Element API](/docs/reference/element-api/) for the initializer shape. -`playhtml.define(name, init)` is the runtime equivalent (callable any time, not just at init) and the recommended way to register a reusable capability — and like `register`, its `init` can use a declarative [`view`](/docs/custom-elements/) instead of the imperative `updateElement` / `onClick` shown below. +`playhtml.define(name, init)` is the runtime equivalent (callable any time, not just at init) and the recommended way to register a reusable capability — and like `register`, its `init` can use a declarative [`view`](/docs/custom-elements/) instead of the imperative `update` / `onClick` shown below. ```js playhtml.init({ extraCapabilities: { "can-pulse": { defaultData: { on: false }, - updateElement: ({ element, data }) => { + update: ({ element, data }) => { element.classList.toggle("pulsing", data.on); }, onClick: (_e, { data, setData }) => setData({ on: !data.on }), diff --git a/apps/docs/src/content/docs/reference/playhtml-client.md b/apps/docs/src/content/docs/reference/playhtml-client.md index 9dcc17b4..236977df 100644 --- a/apps/docs/src/content/docs/reference/playhtml-client.md +++ b/apps/docs/src/content/docs/reference/playhtml-client.md @@ -209,7 +209,7 @@ Throws a console warning if called before `init()` completes sync. ## Custom elements -Use `register` for one custom element and `define` for a reusable capability. Both accept an `ElementInitializer` with either the supported imperative `updateElement` renderer or the experimental declarative `view` renderer. See [Registration API](/docs/reference/view-api/). +Use `register` for one custom element and `define` for a reusable capability. Both accept an `ElementInitializer` with either the supported imperative `update` renderer or the experimental declarative `view` renderer. See [Registration API](/docs/reference/view-api/). ### `register(elementOrId, init)` @@ -238,7 +238,7 @@ const handle = playhtml.register(counter, { data.count += 1; }); }, - updateElement: ({ element, data }) => { + update: ({ element, data }) => { element.textContent = `Clicked ${data.count} times`; }, }); diff --git a/apps/docs/src/content/docs/reference/presence.md b/apps/docs/src/content/docs/reference/presence.md index 3138b4f4..26bb8b59 100644 --- a/apps/docs/src/content/docs/reference/presence.md +++ b/apps/docs/src/content/docs/reference/presence.md @@ -1,6 +1,6 @@ --- title: "Presence & identity" -description: "PlayerIdentity, page-level presence channels, element awareness, and isolated presence rooms." +description: "PlayerIdentity, page-level presence channels, element users, and isolated presence rooms." sidebar: order: 5 --- @@ -113,7 +113,7 @@ room.destroy(); --- -## Element awareness +## Element users Presence scoped to one playhtml element. Same lifetime as page presence — ephemeral, no replay. @@ -121,14 +121,14 @@ Set on the [Element API](/docs/reference/element-api/): | Property / callback field | Role | | --- | --- | -| `myDefaultAwareness` | Your starting awareness value. | -| `awareness` | Read-only array of every user's value in callbacks. | -| `setMyAwareness` | Broadcast your value. | -| `updateElementAwareness` | Imperative hook when awareness changes. | +| `live` | Your starting ephemeral value for this element. | +| `users` | Every current user's identity and live value. | +| `setLive` | Broadcast your value. | +| `update` | Render shared data and element users through one callback. | -Built-in example: `can-hover` uses awareness `{ hover: boolean }` and sets `[data-playhtml-hover]` when anyone hovers. +Built-in example: `can-hover` uses a live `{ hover: boolean }` value and sets `[data-playhtml-hover]` when anyone hovers. -In React, `withSharedState` / `` expose the same fields on render props: `awareness`, `myAwareness`, `setMyAwareness`. +In React, `withSharedState` / `` expose the same `live`, `users`, and `setLive` fields on render props. --- diff --git a/apps/docs/src/content/docs/reference/react-api.md b/apps/docs/src/content/docs/reference/react-api.md index b4dd60f2..27a1977a 100644 --- a/apps/docs/src/content/docs/reference/react-api.md +++ b/apps/docs/src/content/docs/reference/react-api.md @@ -52,14 +52,14 @@ withSharedState( ```tsx interface WithSharedStateConfig { defaultData: T; - myDefaultAwareness?: V; + live?: V; id?: string; tagInfo?: TagType[]; } ``` - **`defaultData`**: required. The initial value of `data`. Survives reload. -- **`myDefaultAwareness`**: optional. Initial value for this user's element awareness. This is ephemeral per-user presence scoped to the element. Does _not_ persist. +- **`live`**: optional. Initial ephemeral value for this user on this element. It does _not_ persist. - **`id`**: optional. Stable id for the element. If omitted, playhtml derives one from the rendered DOM; see [Dynamic elements](/docs/advanced/dynamic-elements/) for why stable ids matter. - **`tagInfo`**: optional. Marks the element as one of the built-in capabilities (e.g. `[TagType.CanToggle]`). See [Capabilities](/docs/capabilities/). @@ -69,16 +69,16 @@ interface WithSharedStateConfig { interface ReactElementEventHandlerData { data: T; setData: (data: T | ((draft: T) => void)) => void; - awareness: V[]; - myAwareness?: V; - setMyAwareness: (data: V) => void; + live?: V; + users: Array<{ user: User; live: V }>; + setLive: (data: V) => void; ref: React.RefObject; } ``` `setData` accepts either a replacement value or a mutator function. See [Data essentials](/docs/data/data-essentials/) for the merge semantics. -`awareness`, `myAwareness`, and `setMyAwareness` are the element-scoped form of [presence](/docs/data/presence/#element-awareness). Use them for live per-user signals tied to this element, not state that should survive reload. +`live`, `users`, and `setLive` are element-scoped per-user data. Use them for signals tied to this element that should disappear when the user leaves. Each `users` entry joins the person's identity with their current `live` value. ### Props-dependent config @@ -99,7 +99,7 @@ Component form of `withSharedState`. Useful when you want JSX children (render-p interface CanPlayElementProps { id?: string; defaultData: T; - myDefaultAwareness?: V; + live?: V; tagInfo?: TagType[]; standalone?: boolean; loading?: LoadingOptions; @@ -111,7 +111,7 @@ interface CanPlayElementProps { ``` - **`id`**: required if the top-level child is a React Fragment. Otherwise defaults to the child's id, or a hash of the child's content. A stable id matters for cross-browser sync; see [Dynamic elements](/docs/advanced/dynamic-elements/). -- **`myDefaultAwareness`**: optional. Initial element awareness for this user. Same lifetime as presence; it clears when the user leaves. +- **`live`**: optional. Initial live value for this user. It clears when the user leaves. - **`standalone`**: when `true`, the element initializes playhtml itself if no `PlayProvider` is present. Use it for one-off components mounted outside your provider tree (e.g. an Astro island). A no-op when a provider already exists. - **`loading`**: controls the loading affordance shown before the element's first sync. See [Loading options](#loading-options). - **`dataSource`**, **`shared`**, **`dataSourceReadOnly`**: wire the element to a shared source across pages or sites. See [Shared data props](#shared-data-props) and the [Shared elements](/docs/advanced/shared-elements/) guide. @@ -504,6 +504,4 @@ The repo has a collection of runnable React examples at [`packages/react/example A few things still in flux in the React package: -- **Per-key persistence config.** Currently persistence is a whole-store choice: `setMyAwareness` for element-scoped presence, `setData` for persistent data, no local-only mode. A future `persistenceOptions` object might let you configure per-key (`none` / `local` / `global`). -- **`awareness` splitting.** `awareness` currently includes the local user; it may split into `myAwareness` + `othersAwareness` for clarity. - **Hook ergonomics.** A pure-hook interface (`useSharedState({ id, defaultData })`) is being evaluated as an alternative to the HOC form. The blocker is that hooks have no natural place to pin a stable `id`. diff --git a/apps/docs/src/content/docs/reference/view-api.md b/apps/docs/src/content/docs/reference/view-api.md index 0179e533..dbcec2d5 100644 --- a/apps/docs/src/content/docs/reference/view-api.md +++ b/apps/docs/src/content/docs/reference/view-api.md @@ -8,7 +8,7 @@ sidebar: Register a custom collaborative element with one initializer object. Use `register` for one element and `define` for a reusable capability. :::caution[Experimental] -The declarative `view` renderer and the lit-html helpers are experimental. `register`, `define`, handles, and the imperative `updateElement` renderer are supported APIs. +The declarative `view` renderer and the lit-html helpers are experimental. `register`, `define`, handles, and the imperative `update` renderer are supported APIs. ::: ```js @@ -28,7 +28,7 @@ const handle = playhtml.register(counter, { data.count += 1; }); }, - updateElement: ({ element, data }) => { + update: ({ element, data }) => { element.textContent = String(data.count); }, }); @@ -63,11 +63,11 @@ const handle = playhtml.getHandle("card-1", "can-move"); ## The `init` object -The full annotated property list is on [Element API](/docs/reference/element-api/#initializer). Both `updateElement` and `view` receive the [callback context](/docs/reference/element-api/#callback-context-ctx). +The full annotated property list is on [Element API](/docs/reference/element-api/#initializer). Both `update` and `view` receive the [callback context](/docs/reference/element-api/#callback-context-ctx). `defaultData` must be an object (or a function that returns one), not a bare value like `0` or `""`. Use `{ count: 0 }`, not `0`. -A valid initializer provides exactly one update path — `view` **or** `updateElement`. +A valid initializer provides exactly one update path — `view` **or** `update`. ## The `view` context @@ -86,7 +86,7 @@ Returned by `register` and `getHandle`. Reads and writes resolve the live handle getData(), // undefined until bound setData(next), setLocalData(next), - setMyAwareness(next), + setLive(next), requestUpdate(), // no-op without a view unregister(), // detach + run onMount cleanup; shared data is kept } diff --git a/apps/docs/src/content/docs/using-react.md b/apps/docs/src/content/docs/using-react.md index 86858de8..71b3caeb 100644 --- a/apps/docs/src/content/docs/using-react.md +++ b/apps/docs/src/content/docs/using-react.md @@ -81,7 +81,7 @@ export const ReactionView = withSharedState( Use the mutator form for counters and other `+/-` updates so each interaction edits the draft value at write time. -Add `myDefaultAwareness` to the config to get element awareness: ephemeral per-user presence scoped to this element, alongside its persistent data. +Add `live` to the config for ephemeral per-user data scoped to this element. Render every current identity and value from `users`, and change yours with `setLive`. ### ``: for when you need JSX children, not a wrapper diff --git a/claude-plugin/skills/building-playhtml-elements/SKILL.md b/claude-plugin/skills/building-playhtml-elements/SKILL.md index 775b8958..aa1e9fd5 100644 --- a/claude-plugin/skills/building-playhtml-elements/SKILL.md +++ b/claude-plugin/skills/building-playhtml-elements/SKILL.md @@ -11,7 +11,7 @@ playhtml makes HTML elements collaborative and real-time via Yjs CRDTs. If the user's request is ambiguous on ANY of these, **stop and ask**: -1. **Persistence**: Should data survive page refresh? (defaultData=yes, awareness=no) +1. **Persistence**: Should data survive page refresh? (`defaultData`=yes, `live`=no) 2. **Shared vs per-user**: Should all users see the same state, or does each user have their own? 3. **Vanilla HTML or React?** @@ -22,7 +22,7 @@ These determine which API and data type to use. Getting them wrong means a rewri | Type | Persists? | Syncs? | Use for | |------|-----------|--------|---------| | `defaultData` | Yes | Yes | Positions, counts, messages, toggles | -| `myDefaultAwareness` | No | Yes | Who's online, typing, hover state | +| `live` | No | Yes | Per-user typing, ready, hover, or selection state | | `dispatchPlayEvent` | No | One-shot | Confetti, notifications | | `localStorage` | Yes | No | Per-user flags ("has reacted") | @@ -40,7 +40,8 @@ import { playhtml } from "https://unpkg.com/playhtml@latest"; const el = document.getElementById("myElement"); playhtml.register(el, { defaultData: { count: 0 }, // REQUIRED - updateElement: ({ element, data }) => { ... }, // REQUIRED + live: { ready: false }, + update: ({ element, data, live, users }) => { ... }, // REQUIRED onClick: (e, { data, setData }) => { ... }, onDrag: (e, { data, setData, localData, setLocalData }) => { ... }, onDragStart: (e, { setLocalData }) => { ... }, @@ -77,7 +78,7 @@ const Counter = withSharedState( ) ); -// Component receives: data, setData, awareness, setMyAwareness, ref +// Component receives: data, setData, live, users, setLive, ref // For events: usePlayContext() → { dispatchPlayEvent, registerPlayEventListener } // For cursors: usePlayContext() → { cursors, configureCursors } ``` @@ -173,11 +174,11 @@ See https://playhtml.fun/docs/data/presence/cursors/ for full API. 1. **Direct element-property setup** (vanilla): Use `playhtml.register(elementOrId, initializer)` so setup does not depend on assigning callbacks to a DOM node before initialization. 2. **Missing `id`**: No id = no sync. Silent failure. -3. **Wrong data type**: Awareness for persistent data (disappears on disconnect) or defaultData for ephemeral presence (leaves stale data). Refer to the Data Types table. +3. **Wrong data type**: `live` for persistent data (it disappears on disconnect) or `defaultData` for ephemeral user state (it leaves stale data). Refer to the Data Types table. 4. **Bad array mutations**: In mutator form, the draft is a Yjs CRDT proxy. Use `push()`/`splice()` only — `shift()`, `pop()`, and `items[i] = x` don't sync correctly. 5. **Replacement form loses fields**: `setData({ x: 5 })` erases `y`. Use replacement only for whole-value writes, or use mutator form for field-level changes. 6. **Deep nesting**: CRDTs work best with flat data. Avoid deeply nested objects. -7. **High-frequency updates**: Don't `setData` on every mousemove. Debounce, or use `setLocalData`/awareness. +7. **High-frequency updates**: Don't `setData` on every mousemove. Debounce, or use `setLocalData`/`live`. - **Worst case — self-triggering write loop**: a callback that writes shared data AND re-runs when that data changes. See the "NEVER write shared data…" section above. This crashed a production room; treat it as a hard rule. -8. **Computed values in state**: Don't store what you can calculate. Compute in `updateElement`/render. +8. **Computed values in state**: Don't store what you can calculate. Compute in `update`/render. 9. **Missing PlayProvider** (React): `withSharedState` silently fails without it. diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index c82dabcb..4d258bc2 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -18,16 +18,24 @@ export * from "./presence-protocol"; */ export type ViewTemplate = unknown; +export interface ElementUser { + user: User; + live: V; +} + export interface ElementInitializer { defaultData?: T | ((element: HTMLElement) => T); defaultLocalData?: U | ((element: HTMLElement) => U); + live?: V | ((element: HTMLElement) => V); + /** @deprecated Use `live`. */ myDefaultAwareness?: V | ((element: HTMLElement) => V); /** - * Imperative update path: receives the current state and mutates the DOM - * directly. Pair with `defaultData`; use `view` instead for declarative - * rendering. - * `view` and `updateElement` are mutually exclusive — providing both is a - * registration-time error. + * Imperative update path. Receives the current state and mutates the DOM. + * Runs when shared data or element live values change. + */ + update?: (data: ElementEventHandlerData) => void; + /** + * @deprecated Use `update`. */ updateElement?: (data: ElementEventHandlerData) => void; /** @@ -44,7 +52,7 @@ export interface ElementInitializer { */ view?: (data: ElementEventHandlerData) => ViewTemplate; /** - * Imperative awareness update path. Required with `myDefaultAwareness`. + * @deprecated Use `update`, which also runs when element live values change. */ updateElementAwareness?: ( data: ElementAwarenessEventHandlerData, @@ -77,10 +85,10 @@ export interface ElementInitializer { } export interface ElementData - extends ElementInitializer { + extends ElementInitializer { data?: T; localData?: U; - awareness?: V; + awareness?: V[]; element: HTMLElement; onChange: (data: T) => void; onAwarenessChange: (data: V) => void; @@ -94,8 +102,14 @@ export interface ElementData export interface ElementEventHandlerData { data: T; localData: U; + live: V | undefined; + users: ElementUser[]; + /** @deprecated Use `users.map(({ live }) => live)`. */ awareness: V[]; + /** @deprecated Use `users`. */ awarenessByStableId: Map; + /** @deprecated Use `live`. */ + myAwareness?: V; element: HTMLElement; /** * Updates the element's shared data. @@ -115,6 +129,8 @@ export interface ElementEventHandlerData { setData: (data: T | ((draft: T) => void)) => void; // TODO: should probably rename to "setTemporaryData" and use setLocalData to set indexeddb data setLocalData: (data: U | ((draft: U) => void)) => void; + setLive: (data: V) => void; + /** @deprecated Use `setLive`. */ setMyAwareness: (data: V) => void; /** * Re-runs the element's `view` and patches the result into the DOM, even @@ -126,17 +142,20 @@ export interface ElementEventHandlerData { } export interface ElementAwarenessEventHandlerData - extends ElementEventHandlerData { - myAwareness?: V; -} + extends ElementEventHandlerData {} export interface ElementSetupData { getData: () => T; getLocalData: () => U; + getLive: () => V | undefined; + getUsers: () => ElementUser[]; + /** @deprecated Use `getUsers`. */ getAwareness: () => V[]; getElement: () => HTMLElement; setData: (data: T | ((draft: T) => void)) => void; setLocalData: (data: U | ((draft: U) => void)) => void; + setLive: (data: V) => void; + /** @deprecated Use `setLive`. */ setMyAwareness: (data: V) => void; /** * Re-runs the element's `view` and patches the result into the DOM. See @@ -387,7 +406,7 @@ export * from "./sharedElements"; // Export cursor types export * from "./cursor-types"; -import type { Cursor, PlayerIdentity } from "./cursor-types"; +import type { Cursor, PlayerIdentity, User } from "./cursor-types"; export type PageDataSetter = [T] extends [object] ? T | ((draft: T) => void) diff --git a/packages/playhtml/src/__tests__/element-awareness-sync.test.ts b/packages/playhtml/src/__tests__/element-awareness-sync.test.ts index a896ab7f..51516507 100644 --- a/packages/playhtml/src/__tests__/element-awareness-sync.test.ts +++ b/packages/playhtml/src/__tests__/element-awareness-sync.test.ts @@ -10,6 +10,18 @@ import { sentChannelUpdates, } from "./presence-test-utils"; +function publishRemoteIdentity(identity: Record): void { + const providers = (globalThis as any).PLAYHTML_TEST_PROVIDERS as any[]; + const provider = providers.at(-1); + if (!provider) throw new Error("Expected test provider"); + provider.awareness.getStates().set(2, { __playhtml_identity__: identity }); + provider.emit("change", { + added: [], + updated: [2], + removed: [], + }); +} + describe("element awareness sync", () => { beforeEach(async () => { document.body.innerHTML = ""; @@ -72,6 +84,79 @@ describe("element awareness sync", () => { expect(byStableIdSnapshots.at(-1)?.size).toBe(0); }); + it("joins identity with element live values and rerenders on identity changes", async () => { + const updates: any[] = []; + const el = document.createElement("div"); + el.id = "live-users-card"; + document.body.appendChild(el); + + playhtml.register(el, { + live: { active: true }, + update: (context: any) => updates.push(context), + } as any); + await new Promise((resolve) => setTimeout(resolve, 0)); + + const socket = getPresenceSocketForRoom(playhtml.roomId); + socket.receive({ + type: "presence-sync", + peers: { + "conn-remote": { + identity: { + publicKey: "pk_remote_user", + name: "Mina", + playerStyle: { colorPalette: ["blue"] }, + }, + "element:shard:0": { + v: 1, + entries: [["can-play", "live-users-card", { active: false }]], + }, + }, + }, + }); + + expect(updates.at(-1).live).toEqual({ active: true }); + expect(updates.at(-1).users).not.toContainEqual( + expect.objectContaining({ + user: expect.objectContaining({ pid: "pk_remote_user" }), + }), + ); + + const updateCount = updates.length; + publishRemoteIdentity({ + publicKey: "pk_remote_user", + name: "Mina", + playerStyle: { colorPalette: ["blue"] }, + }); + + expect(updates).toHaveLength(updateCount + 1); + expect(updates.at(-1).users).toContainEqual({ + user: { + pid: "pk_remote_user", + name: "Mina", + color: "blue", + isMe: false, + }, + live: { active: false }, + }); + + publishRemoteIdentity({ + publicKey: "pk_remote_user", + name: "Jo", + playerStyle: { colorPalette: ["purple"] }, + }); + + expect(updates).toHaveLength(updateCount + 2); + expect(updates.at(-1).users).toContainEqual({ + user: { + pid: "pk_remote_user", + name: "Jo", + color: "purple", + isMe: false, + }, + live: { active: false }, + }); + }); + it("publishes element awareness through the page room when cursors use another room", async () => { document.body.innerHTML = ""; (globalThis as any).PLAYHTML_TEST_PROVIDERS = []; diff --git a/packages/playhtml/src/__tests__/elements.test.ts b/packages/playhtml/src/__tests__/elements.test.ts index 9fea8865..24475f6f 100644 --- a/packages/playhtml/src/__tests__/elements.test.ts +++ b/packages/playhtml/src/__tests__/elements.test.ts @@ -375,4 +375,56 @@ describe("ElementHandler", () => { expect(onAwarenessChange).toHaveBeenCalledWith({ me: "X" }); expect(triggerAwarenessUpdate).toHaveBeenCalled(); }); + + it("uses one update path for live changes and exposes live users", () => { + const update = vi.fn(); + const updateElementAwareness = vi.fn(); + const onAwarenessChange = vi.fn(); + const remoteUser = { + pid: "remote-user", + name: "Mina", + color: "blue", + isMe: false, + }; + + const handler = new ElementHandler( + { + element, + live: { typing: false }, + update, + updateElementAwareness, + onChange: vi.fn(), + onAwarenessChange, + triggerAwarenessUpdate: vi.fn(), + } as any, + { + getUsers: (byStableId: Map) => + byStableId.has(remoteUser.pid) + ? [{ user: remoteUser, live: byStableId.get(remoteUser.pid)! }] + : [], + } as any, + ); + update.mockClear(); + updateElementAwareness.mockClear(); + + handler.updateAwareness( + [{ typing: true }], + new Map([[remoteUser.pid, { typing: true }]]), + ); + + expect(updateElementAwareness).toHaveBeenCalledTimes(1); + expect(update).toHaveBeenCalledTimes(1); + expect(update).toHaveBeenCalledWith( + expect.objectContaining({ + live: { typing: false }, + users: [{ user: remoteUser, live: { typing: true } }], + setLive: expect.any(Function), + myAwareness: { typing: false }, + setMyAwareness: expect.any(Function), + }), + ); + + update.mock.calls[0][0].setLive({ typing: true }); + expect(onAwarenessChange).toHaveBeenCalledWith({ typing: true }); + }); }); diff --git a/packages/playhtml/src/__tests__/main.basic.test.ts b/packages/playhtml/src/__tests__/main.basic.test.ts index d2b8e3d5..b952222b 100644 --- a/packages/playhtml/src/__tests__/main.basic.test.ts +++ b/packages/playhtml/src/__tests__/main.basic.test.ts @@ -155,46 +155,38 @@ describe("playhtml basic setup with SyncedStore", () => { { id: "empty-widget", props: {}, - message: "updateElement, view, or updateElementAwareness", + message: "update, updateElement, view, or updateElementAwareness", }, { id: "data-without-render", props: { defaultData: {} }, - message: "defaultData requires updateElement or view", + message: "defaultData requires update, updateElement, or view", }, { id: "data-with-awareness-render-only", props: { defaultData: {}, updateElementAwareness: vi.fn() }, - message: "defaultData requires updateElement or view", + message: "defaultData requires update, updateElement, or view", }, { id: "render-without-data", props: { updateElement: vi.fn() }, - message: "updateElement or view requires defaultData", + message: "update, updateElement, or view requires defaultData or live", }, { id: "view-without-data", props: { view: vi.fn() }, - message: "updateElement or view requires defaultData", + message: "update, updateElement, or view requires defaultData or live", }, { id: "render-with-awareness-render-without-data", props: { updateElement: vi.fn(), updateElementAwareness: vi.fn() }, - message: "updateElement or view requires defaultData", + message: "update, updateElement, or view requires defaultData or live", }, { id: "awareness-without-render", props: { myDefaultAwareness: { seated: false } }, - message: "myDefaultAwareness requires updateElementAwareness", - }, - { - id: "awareness-with-data-render-only", - props: { - defaultData: {}, - updateElement: vi.fn(), - myDefaultAwareness: { seated: false }, - }, - message: "myDefaultAwareness requires updateElementAwareness", + message: + "myDefaultAwareness requires update, updateElement, view, or updateElementAwareness", }, { id: "primitive-default-data", diff --git a/packages/playhtml/src/__tests__/view-api.test.ts b/packages/playhtml/src/__tests__/view-api.test.ts index ef2aecd3..67693599 100644 --- a/packages/playhtml/src/__tests__/view-api.test.ts +++ b/packages/playhtml/src/__tests__/view-api.test.ts @@ -342,6 +342,35 @@ describe("rail 2: lifecycle & guards", () => { }); describe("rail 2: validation", () => { + it("accepts live with the preferred update renderer", () => { + expect(() => + playhtml.register("live-update", { + live: { typing: false }, + update: () => {}, + } as any), + ).not.toThrow(); + }); + + it("throws when update and updateElement are both provided", () => { + expect(() => + playhtml.register("two-updates", { + defaultData: {}, + update: () => {}, + updateElement: () => {}, + } as any), + ).toThrow(/both `update` and `updateElement`/); + }); + + it("throws when live and myDefaultAwareness are both provided", () => { + expect(() => + playhtml.register("two-live-defaults", { + live: {}, + myDefaultAwareness: {}, + update: () => {}, + } as any), + ).toThrow(/both `live` and `myDefaultAwareness`/); + }); + it("throws when view and updateElement are both provided", () => { expect(() => playhtml.register("bad-1", { diff --git a/packages/playhtml/src/awareness-utils.ts b/packages/playhtml/src/awareness-utils.ts index 3893fb90..e619acac 100644 --- a/packages/playhtml/src/awareness-utils.ts +++ b/packages/playhtml/src/awareness-utils.ts @@ -41,6 +41,18 @@ export function getElementAwarenessFingerprint( const tagKeys = Object.keys(state) .filter((k) => !k.startsWith("__")) .sort(); + if (tagKeys.length > 0) { + const cursorData = state.__playhtml_cursors__ as + | { playerIdentity?: unknown } + | undefined; + const identity = + state.__playhtml_identity__ ?? cursorData?.playerIdentity ?? null; + try { + parts.push(`${clientId}:identity:${JSON.stringify(identity)}`); + } catch { + parts.push(`${clientId}:identity:null`); + } + } for (const tag of tagKeys) { const tagData = state[tag]; if (tagData == null || typeof tagData !== "object") continue; diff --git a/packages/playhtml/src/element-awareness.ts b/packages/playhtml/src/element-awareness.ts index 1dd41398..6c171e40 100644 --- a/packages/playhtml/src/element-awareness.ts +++ b/packages/playhtml/src/element-awareness.ts @@ -207,7 +207,7 @@ export class ElementAwarenessClient { this.publishScheduled = false; if (this.destroyed) return; if (this.onAwarenessChange) { - this.lastAwarenessFingerprint = fingerprintAwareness( + this.lastAwarenessFingerprint = this.fingerprintCurrentAwareness( this.currentAwareness, ); } @@ -306,13 +306,44 @@ export class ElementAwarenessClient { // (addShardEntries reads only entries), so a keepalive re-stamp — or the // periodic sweep touching an unrelated peer — produces an identical // fingerprint and does not re-fire the awareness callback. - const fingerprint = fingerprintAwareness(awareness); + const fingerprint = this.fingerprintCurrentAwareness(awareness); if (fingerprint === this.lastAwarenessFingerprint) return; this.lastAwarenessFingerprint = fingerprint; this.currentAwareness = awareness; this.onAwareness(awareness); } + private fingerprintCurrentAwareness( + awareness: ElementAwarenessMap, + ): string { + const liveFingerprint = fingerprintAwareness(awareness); + const liveUserIds = new Set(); + for (const entry of awareness.values()) { + for (const stableId of entry.byStableId.keys()) { + liveUserIds.add(stableId); + } + } + + const identityParts: string[] = []; + const selfIdentity = this.getIdentity(); + if (liveUserIds.has(selfIdentity.publicKey)) { + identityParts.push( + `${selfIdentity.publicKey}:${JSON.stringify(selfIdentity)}`, + ); + } + const peers = this.transport.peers.getPeers(); + for (const connectionId of Array.from(peers.keys()).sort()) { + const channels = peers.get(connectionId); + if (!channels) continue; + const stableId = getPeerPublicKey(channels) ?? connectionId; + if (!liveUserIds.has(stableId)) continue; + identityParts.push( + `${stableId}:${JSON.stringify(channels.identity ?? null)}`, + ); + } + return `${liveFingerprint}|${identityParts.sort().join("|")}`; + } + private emitLocalAwareness(tag: string, elementId: string): void { if (!this.onAwarenessChange) { this.emit(); diff --git a/packages/playhtml/src/elements.ts b/packages/playhtml/src/elements.ts index 5e440bdf..e2b4e3a1 100644 --- a/packages/playhtml/src/elements.ts +++ b/packages/playhtml/src/elements.ts @@ -7,6 +7,7 @@ import { ElementData, ElementEventHandlerData, ElementSetupData, + ElementUser, ModifierKey, ViewTemplate, observeElementChanges, @@ -23,8 +24,12 @@ const debounce = (fn: Function, ms = 300) => { type ElementDataWrite = T | ((draft: T) => void); -interface ElementHandlerOptions { +interface ElementHandlerOptions { scheduleSetupDataWrite?: (write: () => void) => void; + getUsers?: ( + byStableId: Map, + selfLive: V | undefined, + ) => ElementUser[]; } // TODO: turn this into just an extension of HTMLElement and initialize all the methods / do all the state tracking @@ -43,7 +48,7 @@ export class ElementHandler { resetShortcut?: ModifierKey; // TODO: change this to receive the delta instead of the whole data object so you don't have to maintain // internal state for expressing the delta. - updateElement?: (data: ElementEventHandlerData) => void; + update?: (data: ElementEventHandlerData) => void; view?: (data: ElementEventHandlerData) => ViewTemplate; updateElementAwareness?: ( data: ElementAwarenessEventHandlerData @@ -63,6 +68,10 @@ export class ElementHandler { private descendantObserver?: MutationObserver; private dataUpdateListeners = new Set<() => void>(); private scheduleSetupDataWrite?: (write: () => void) => void; + private getUsers: ( + byStableId: Map, + selfLive: V | undefined, + ) => ElementUser[]; private clickListener?: (e: MouseEvent) => void; private touchStartListener?: (e: TouchEvent) => void; private mouseDownListener?: (e: MouseEvent) => void; @@ -84,8 +93,8 @@ export class ElementHandler { ) => void; constructor( - elementData: ElementData, - options: ElementHandlerOptions = {}, + elementData: ElementData, + options: ElementHandlerOptions = {}, ) { const { element, @@ -93,9 +102,11 @@ export class ElementHandler { onAwarenessChange, defaultData, defaultLocalData, + live, myDefaultAwareness, data, awareness: awarenessData, + update, updateElement, view, updateElementAwareness, @@ -106,6 +117,7 @@ export class ElementHandler { } = elementData; // console.log("🔨 constructing ", element.id); this.scheduleSetupDataWrite = options.scheduleSetupDataWrite; + this.getUsers = options.getUsers ?? (() => []); this.element = element; this.view = view; this.devMode = devMode; @@ -114,24 +126,23 @@ export class ElementHandler { this.localData = defaultLocalData instanceof Function ? defaultLocalData(element) - : defaultLocalData; + : (defaultLocalData as U); this.triggerAwarenessUpdate = triggerAwarenessUpdate; this.onChange = onChange; this.debouncedOnChange = debounce(this.onChange, debounceMs); this.onAwarenessChange = onAwarenessChange; - this.updateElement = updateElement; + this.update = update ?? updateElement; this.updateElementAwareness = updateElementAwareness; const initialData = data === undefined ? this.defaultData : data; if (awarenessData !== undefined) { this.awareness = awarenessData; } - const myInitialAwareness = - myDefaultAwareness instanceof Function - ? myDefaultAwareness(element) - : myDefaultAwareness; - if (myInitialAwareness !== undefined) { - this.setMyAwareness(myInitialAwareness); + const initialLive = live !== undefined ? live : myDefaultAwareness; + const myInitialLive = + initialLive instanceof Function ? initialLive(element) : initialLive; + if (myInitialLive !== undefined) { + this.setLive(myInitialLive); } // Needed to get around the typescript error even though it is assigned in __data. this._data = initialData as T; @@ -190,6 +201,7 @@ export class ElementHandler { element, onChange, onAwarenessChange, + update, updateElement, view, updateElementAwareness, @@ -200,25 +212,25 @@ export class ElementHandler { debounceMs, triggerAwarenessUpdate, devMode, - }: ElementData) { + }: ElementData) { this.triggerAwarenessUpdate = triggerAwarenessUpdate; this.onChange = onChange; this.debouncedOnChange = debounce(this.onChange, debounceMs); this.onAwarenessChange = onAwarenessChange; - this.updateElement = updateElement; + this.update = update ?? updateElement; this.view = view; this.devMode = devMode; - // `view` and `updateElement` are mutually exclusive. register/define throw + // `view` and the imperative update path are mutually exclusive. register/define throw // on this, but React props / extraCapabilities reach this shared path // without that check, so enforce it here: `view` wins and `updateElement` // is dropped (with a diagnostic) instead of silently ignored. - if (view && this.updateElement) { + if (view && this.update) { console.error( - `[playhtml] "${element.id}" provides both \`view\` and \`updateElement\`. ` + - `They are mutually exclusive — \`view\` is used and \`updateElement\` is ignored.`, + `[playhtml] "${element.id}" provides both \`view\` and an imperative update renderer. ` + + `They are mutually exclusive. \`view\` is used and the imperative renderer is ignored.`, ); - this.updateElement = undefined; + this.update = undefined; } // In view mode, element-level event handlers are not wired — interactions @@ -436,7 +448,7 @@ export class ElementHandler { // observer only fires when child nodes actually change. return; } - this.updateElement?.(this.getEventHandlerData()); + this.update?.(this.getEventHandlerData()); } /** @@ -496,12 +508,7 @@ export class ElementHandler { error, ); } - // Views render from awareness too (e.g. "3 people here"), so an awareness - // change must re-render — even when updateElementAwareness is also present - // (otherwise the view goes stale while the callback runs). - if (this.view) { - this.render(); - } + this.render(); } getEventHandlerData(): ElementEventHandlerData { @@ -509,11 +516,15 @@ export class ElementHandler { element: this.element, data: this.data, localData: this.localData, + live: this.selfAwareness, + users: this.getUsers(this.awarenessByStableId, this.selfAwareness), awareness: this.awareness, awarenessByStableId: this.awarenessByStableId, + myAwareness: this.selfAwareness, setData: (newData) => this.setData(newData), setLocalData: (newData) => this.setLocalData(newData), - setMyAwareness: (newData) => this.setMyAwareness(newData), + setLive: (newData) => this.setLive(newData), + setMyAwareness: (newData) => this.setLive(newData), requestUpdate: () => this.requestUpdate(), }; } @@ -521,19 +532,22 @@ export class ElementHandler { getAwarenessEventHandlerData(): ElementAwarenessEventHandlerData { return { ...this.getEventHandlerData(), - myAwareness: this.selfAwareness, }; } - getSetupData(): ElementSetupData { + getSetupData(): ElementSetupData { return { getElement: () => this.element, getData: () => this.data, getLocalData: () => this.localData, + getLive: () => this.selfAwareness, + getUsers: () => + this.getUsers(this.awarenessByStableId, this.selfAwareness), getAwareness: () => this.awareness, setData: (newData) => this.setSetupData(newData), setLocalData: (newData) => this.setLocalData(newData), - setMyAwareness: (newData) => this.setMyAwareness(newData), + setLive: (newData) => this.setLive(newData), + setMyAwareness: (newData) => this.setLive(newData), requestUpdate: () => this.requestUpdate(), }; } @@ -582,10 +596,10 @@ export class ElementHandler { } // TODO: this should be keyed on the element to avoid conflicts - setMyAwareness(data: V): void { + setLive(data: V): void { // In view mode an awareness change re-renders, so writing awareness during // render would loop. Reject it like the other write paths. - if (this.rejectWriteDuringRender("setMyAwareness")) return; + if (this.rejectWriteDuringRender("setLive")) return; if (data === this.selfAwareness) { // avoid duplicate broadcasts return; @@ -600,6 +614,11 @@ export class ElementHandler { this.triggerAwarenessUpdate?.(); } + /** @deprecated Use `setLive`. */ + setMyAwareness(data: V): void { + this.setLive(data); + } + setDataDebounced(data: T) { this.debouncedOnChange(data); } diff --git a/packages/playhtml/src/index.ts b/packages/playhtml/src/index.ts index 31a9d4bd..538e5048 100644 --- a/packages/playhtml/src/index.ts +++ b/packages/playhtml/src/index.ts @@ -17,6 +17,8 @@ import { deepReplaceIntoProxy, clonePlain, observeElementChanges, + type ElementUser, + type User, } from "@playhtml/common"; import { listSharedElements as devListSharedElements } from "./shared-elements"; import { @@ -216,6 +218,8 @@ let currentCursorRoomId = ""; // onPresenceChange subscriptions) captured before navigation keep working. let presenceFacade: PresenceFacade | null = null; let usersAPI: UsersAPI | null = null; +let usersElementRenderUnsubscribe: (() => void) | null = null; +let lastElementUsersFingerprint: string | null = null; // Stable indirection between the page presence client's "cursor" channel and // whichever cursor client currently exists. The cursor client is torn down and @@ -726,6 +730,9 @@ function acquirePresenceTransport( identity: resolveMyIdentity(), page: getPresencePage(), }); + if (room === elementAwarenessRoom) { + elementAwarenessClient?.refresh(); + } } catch (error) { // join validates identity and can throw (e.g. an extension-injected // identity edge case). Surface it — the empty catch also let latestJoin @@ -1674,6 +1681,10 @@ async function initPlayHTMLOnce() { onCursorPresencesChange: (callback) => cursorClient?.onCursorPresencesChange(callback), }); + lastElementUsersFingerprint = null; + usersElementRenderUnsubscribe = usersAPI.onChange( + renderElementsWithLiveUsers, + ); // Initialize cursor tracking immediately after provider creation buildCursors({ @@ -1879,23 +1890,22 @@ function createPlayElementData( tagInfo.defaultData === undefined ? undefined : ensureElementProxy(tag, elementId, initialData as TData); - const initialAwareness = getElementAwareness(tag, elementId); + const publishedLive = getElementAwareness(tag, elementId); + const configuredLive = + tagInfo.live !== undefined ? tagInfo.live : tagInfo.myDefaultAwareness; + const initialLive = + publishedLive ?? + (configuredLive instanceof Function + ? configuredLive(element) + : configuredLive); const elementData: ElementData = { ...tagInfo, - myDefaultAwareness: - initialAwareness !== undefined - ? initialAwareness - : tagInfo.myDefaultAwareness, + live: initialLive, devMode: configuredOptions?.developmentMode ?? false, // Always provide a plain snapshot to render paths data: clonePlain(dataProxy), - awareness: - initialAwareness !== undefined - ? [initialAwareness] - : tagInfo.myDefaultAwareness !== undefined - ? [tagInfo.myDefaultAwareness] - : undefined, + awareness: initialLive !== undefined ? [initialLive] : undefined, element, onChange: (newData: TData) => { if (dataProxy === undefined) { @@ -1953,6 +1963,46 @@ function createPlayElementData( return elementData; } +function getElementUsers( + byStableId: Map, + selfLive: V | undefined, +): ElementUser[] { + if (!usersAPI) return []; + + const liveByUser = new Map(byStableId); + if (selfLive !== undefined) { + liveByUser.set(usersAPI.me.pid, selfLive); + } + + const result: ElementUser[] = []; + for (const user of usersAPI.getAll()) { + const live = liveByUser.get(user.pid); + if (live === undefined) continue; + result.push({ user, live }); + } + return result; +} + +function renderElementsWithLiveUsers(users: User[]): void { + const fingerprint = JSON.stringify( + [...users].sort((a, b) => a.pid.localeCompare(b.pid)), + ); + if (fingerprint === lastElementUsersFingerprint) return; + lastElementUsersFingerprint = fingerprint; + + for (const handlers of elementHandlers.values()) { + for (const handler of handlers.values()) { + if ( + handler.selfAwareness === undefined && + handler.awarenessByStableId.size === 0 + ) { + continue; + } + safeInvoke(() => handler.render(), "element users render"); + } + } +} + function isCorrectElementInitializer( tagInfo: ElementInitializer | Partial | undefined, ): tagInfo is ElementInitializer { @@ -1974,30 +2024,46 @@ function getElementInitializerValidationIssues( tagInfo.defaultData !== null && (typeof tagInfo.defaultData === "object" || typeof tagInfo.defaultData === "function"); + const hasUpdate = typeof tagInfo.update === "function"; const hasUpdateElement = typeof tagInfo.updateElement === "function"; const hasView = typeof tagInfo.view === "function"; - const hasDataUpdate = hasUpdateElement || hasView; + const hasNormalUpdate = hasUpdate || hasUpdateElement || hasView; + const hasLive = tagInfo.live !== undefined; const hasMyDefaultAwareness = tagInfo.myDefaultAwareness !== undefined; const hasUpdateElementAwareness = typeof tagInfo.updateElementAwareness === "function"; - const hasUpdateFunction = hasDataUpdate || hasUpdateElementAwareness; + const hasUpdateFunction = hasNormalUpdate || hasUpdateElementAwareness; + + if (hasUpdate && hasUpdateElement) { + issues.push("update and updateElement are mutually exclusive"); + } + + if (hasLive && hasMyDefaultAwareness) { + issues.push("live and myDefaultAwareness are mutually exclusive"); + } if (hasDefaultData && !hasValidDefaultData) { issues.push("defaultData must be an object or function"); } - if (hasDefaultData && !hasDataUpdate) { - issues.push("defaultData requires updateElement or view"); - } else if (!hasDefaultData && hasDataUpdate) { - issues.push("updateElement or view requires defaultData"); + if (hasDefaultData && !hasNormalUpdate) { + issues.push("defaultData requires update, updateElement, or view"); + } else if (!hasDefaultData && !hasLive && !hasMyDefaultAwareness && hasNormalUpdate) { + issues.push("update, updateElement, or view requires defaultData or live"); + } + + if (hasLive && !hasNormalUpdate) { + issues.push("live requires update, updateElement, or view"); } - if (hasMyDefaultAwareness && !hasUpdateElementAwareness) { - issues.push("myDefaultAwareness requires updateElementAwareness"); + if (hasMyDefaultAwareness && !hasNormalUpdate && !hasUpdateElementAwareness) { + issues.push( + "myDefaultAwareness requires update, updateElement, view, or updateElementAwareness", + ); } if (issues.length === 0 && !hasUpdateFunction) { - issues.push("updateElement, view, or updateElementAwareness"); + issues.push("update, updateElement, view, or updateElementAwareness"); } return issues; @@ -2011,7 +2077,9 @@ function getCustomElementProps(element: HTMLElement) { const keys: (keyof ElementInitializer)[] = [ "defaultData", "defaultLocalData", + "live", "myDefaultAwareness", + "update", "updateElement", "view", "updateElementAwareness", @@ -2453,6 +2521,9 @@ export async function resetPlayHTML(): Promise { teardownPresenceClient(); teardownCursors(); teardownMainProvider(); + usersElementRenderUnsubscribe?.(); + usersElementRenderUnsubscribe = null; + lastElementUsersFingerprint = null; try { usersAPI?.destroy(); } catch {} usersAPI = null; @@ -2732,12 +2803,12 @@ async function setupPlayElementForTag( attachSyncedStoreObserver(tag as string, elementId); return; } else { - const handler = new ElementHandler( - elementData, - tag === TagType.CanMirror + const handler = new ElementHandler(elementData, { + getUsers: getElementUsers, + ...(tag === TagType.CanMirror ? { scheduleSetupDataWrite: (write) => canMirrorDataQueue.queue(write) } - : undefined, - ); + : {}), + }); tagElementHandlers.set(elementId, handler); // View handlers can emit capability descendants (mount points for // `define`d capabilities / `register`ed ids). Bind the current children and @@ -3004,6 +3075,8 @@ export interface PlayElementHandle { getData(): T | undefined; setData(next: T | ((draft: T) => void)): void; setLocalData(next: U | ((draft: U) => void)): void; + setLive(next: V): void; + /** @deprecated Use `setLive`. */ setMyAwareness(next: V): void; /** Re-run the view now (for clock-driven views). No-op without a view. */ requestUpdate(): void; @@ -3018,9 +3091,19 @@ function validateRegisteredInitializer( name: string, init: ElementInitializer, ): void { - if (init.view && init.updateElement) { + if (init.update && init.updateElement) { throw new Error( - `[playhtml] "${name}" defines both \`view\` and \`updateElement\`. They are mutually exclusive — pick one.`, + `[playhtml] "${name}" defines both \`update\` and \`updateElement\`. Pick one imperative renderer.`, + ); + } + if (init.live !== undefined && init.myDefaultAwareness !== undefined) { + throw new Error( + `[playhtml] "${name}" defines both \`live\` and \`myDefaultAwareness\`. Pick one element live default.`, + ); + } + if (init.view && (init.update || init.updateElement)) { + throw new Error( + `[playhtml] "${name}" defines both \`view\` and an imperative update renderer. They are mutually exclusive. Pick one.`, ); } if (init.view && (init.onClick || init.onDrag || init.onDragStart)) { @@ -3103,10 +3186,15 @@ function createPlayElementHandle( if (!handler) return warnUnboundHandleWrite("setLocalData", elementId); handler.setLocalData(next); }, + setLive: (next) => { + const handler = findHandlerForElementId(elementId, tag); + if (!handler) return warnUnboundHandleWrite("setLive", elementId); + handler.setLive(next); + }, setMyAwareness: (next) => { const handler = findHandlerForElementId(elementId, tag); if (!handler) return warnUnboundHandleWrite("setMyAwareness", elementId); - handler.setMyAwareness(next); + handler.setLive(next); }, requestUpdate: () => { const handler = findHandlerForElementId(elementId, tag); @@ -3126,7 +3214,7 @@ function createPlayElementHandle( } /** - * Registers a `view`/`updateElement` initializer for a single element by id or + * Registers a `view`/`update` initializer for a single element by id or * DOM reference. Callable before or after `init()`. An id registration binds * once the element exists; an element registration binds the provided node. * Returns a handle for reads/writes from outside the view. @@ -3458,6 +3546,7 @@ export { export type { ElementAwarenessEventHandlerData, ElementInitializer, + ElementUser, PageDataChannel, PageDataSetter, PlayerIdentity, diff --git a/packages/react/src/__tests__/binding.integration.test.tsx b/packages/react/src/__tests__/binding.integration.test.tsx index 528bb3b3..e75ac030 100644 --- a/packages/react/src/__tests__/binding.integration.test.tsx +++ b/packages/react/src/__tests__/binding.integration.test.tsx @@ -1,10 +1,15 @@ // ABOUTME: Tests React hooks and element bindings against the real playhtml core. // ABOUTME: Verifies binding cleanup and presence-room readiness across navigation. import React from "react"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { act, cleanup, render, waitFor } from "@testing-library/react"; import { elementHandlers, playhtml, resetPlayHTML, TagType } from "playhtml"; -import { PlayProvider, usePresenceRoom, withSharedState } from "../index"; +import { + CanMoveElement, + PlayProvider, + usePresenceRoom, + withSharedState, +} from "../index"; describe("CanPlayElement binding lifecycle", () => { beforeEach(async () => { @@ -60,6 +65,24 @@ describe("CanPlayElement binding lifecycle", () => { unmount(); }); + it("registers built-in capabilities with one renderer name", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + render( + +
move me
+
, + ); + + await waitFor(() => { + expect( + elementHandlers.get(TagType.CanMove)?.get("moving-card"), + ).toBeDefined(); + }); + expect(errorSpy).not.toHaveBeenCalledWith( + expect.stringContaining("update and updateElement are mutually exclusive"), + ); + }); + it("lets a newly mounted movable element respond during the same commit", async () => { const MovableWord = withSharedState< { x: number; y: number }, diff --git a/packages/react/src/__tests__/elements.test.tsx b/packages/react/src/__tests__/elements.test.tsx index e7fc9cb2..a72f04e2 100644 --- a/packages/react/src/__tests__/elements.test.tsx +++ b/packages/react/src/__tests__/elements.test.tsx @@ -425,6 +425,8 @@ describe("CanPlayElement with built-in capabilities", () => { act(() => { (element as any).updateElement({ data: ["a", "b"], + live: undefined, + users: [], awareness: [], awarenessByStableId: new Map(), myAwareness: undefined, @@ -432,6 +434,7 @@ describe("CanPlayElement with built-in capabilities", () => { localData: [], setData: vi.fn(), setLocalData: vi.fn(), + setLive: vi.fn(), setMyAwareness: vi.fn(), }); }); @@ -524,6 +527,53 @@ describe("CanPlayElement with built-in capabilities", () => { expect(lastByStableId?.get("alice")).toEqual({ isHovering: true }); }); + it("exposes live users through the React render context", () => { + vi.spyOn(playhtml, "setupPlayElement").mockImplementation(() => {}); + vi.spyOn(playhtml, "removePlayElement").mockImplementation(() => {}); + + let renderedContext: any; + const { container } = render( + + {(context) => { + renderedContext = context; + return
; + }} + , + ); + const element = container.querySelector("[can-play]") as HTMLElement; + + act(() => { + (element as any).updateElement({ + data: {}, + localData: undefined, + live: { active: true }, + users: [ + { + user: { pid: "alice", color: "blue", isMe: false }, + live: { active: false }, + }, + ], + awareness: [{ active: false }], + awarenessByStableId: new Map([["alice", { active: false }]]), + myAwareness: { active: true }, + element, + setData: vi.fn(), + setLocalData: vi.fn(), + setLive: vi.fn(), + setMyAwareness: vi.fn(), + }); + }); + + expect(renderedContext.live).toEqual({ active: true }); + expect(renderedContext.users).toEqual([ + { + user: { pid: "alice", color: "blue", isMe: false }, + live: { active: false }, + }, + ]); + expect(renderedContext.setLive).toBeTypeOf("function"); + }); + it("works without a capability updateElement (pure can-play)", () => { const { container } = render( diff --git a/packages/react/src/index.tsx b/packages/react/src/index.tsx index 8d66c5d0..624afd8a 100644 --- a/packages/react/src/index.tsx +++ b/packages/react/src/index.tsx @@ -9,7 +9,14 @@ import { useRef, useState, } from "react"; -import { ElementAwarenessEventHandlerData, ElementInitializer, TagType, elementHandlers, getIdForElement } from "playhtml"; +import { + ElementAwarenessEventHandlerData, + ElementInitializer, + ElementUser, + TagType, + elementHandlers, + getIdForElement, +} from "playhtml"; import playhtml from "./playhtml-singleton"; import { cloneThroughFragments, @@ -259,7 +266,7 @@ export function CanPlayElement({ const ref = useRef(null); const registeredBindingRef = useRef(undefined); const warningKeysRef = useRef>(new Set()); - const { defaultData, myDefaultAwareness } = elementProps; + const { defaultData, live: configuredLive, myDefaultAwareness } = elementProps; const resolveDefaultData = (fnOrValue: T | ((el: HTMLElement) => T)) => typeof fnOrValue === "function" ? // @ts-ignore @@ -278,26 +285,29 @@ export function CanPlayElement({ ? resolveDefaultData(defaultData as T | ((el: HTMLElement) => T)) : undefined, ); - const initialAwareness = resolveDefaultAwareness( - myDefaultAwareness as V | ((el: HTMLElement) => V) | undefined, + const initialLive = resolveDefaultAwareness( + (configuredLive !== undefined ? configuredLive : myDefaultAwareness) as + | V + | ((el: HTMLElement) => V) + | undefined, ); const [awareness, setAwareness] = useState( - initialAwareness ? [initialAwareness] : [], + initialLive === undefined ? [] : [initialLive], ); const [awarenessByStableId, setAwarenessByStableId] = useState< Map >(new Map()); - const [myAwareness, setMyAwareness] = useState( - initialAwareness, - ); + const [live, setLive] = useState(initialLive); + const [users, setUsers] = useState[]>([]); - // Capture the capability's original updateElement/updateElementAwareness so we can - // compose them with the React state updater below. These come from the built-in + // Capture the capability's original render callbacks so we can compose them + // with the React state updater below. These come from the built-in // TagTypeToElement definitions (e.g. CanMove applies element.style.transform). // They arrive as extra runtime props via {...TagTypeToElement[TagType.CanMove]} but // are omitted from the CanPlayProps type since React components don't normally use them. - const capabilityUpdateElement = (elementProps as any).updateElement as - | ElementInitializer["updateElement"] + const capabilityUpdate = ((elementProps as any).update ?? + (elementProps as any).updateElement) as + | ElementInitializer["update"] | undefined; const capabilityUpdateElementAwareness = (elementProps as any) .updateElementAwareness as @@ -319,16 +329,21 @@ export function CanPlayElement({ ? prev : handlerData.awarenessByStableId ); - setMyAwareness((prev) => - isDeepEqual(prev, handlerData.myAwareness) + setLive((prev) => + isDeepEqual(prev, handlerData.live) + ? prev + : (handlerData.live as V | undefined) + ); + setUsers((prev) => + isDeepEqual(prev, handlerData.users) ? prev - : handlerData.myAwareness + : (handlerData.users as ElementUser[]) ); }; const updateElement: ElementInitializer["updateElement"] = (handlerData) => { syncReactState(handlerData as ElementAwarenessEventHandlerData); - capabilityUpdateElement?.(handlerData); + capabilityUpdate?.(handlerData); }; const updateElementAwareness: ElementInitializer["updateElementAwareness"] = ( @@ -368,13 +383,19 @@ export function CanPlayElement({ if (ref.current) { const element = ref.current; for (const [key, value] of Object.entries(elementProps)) { - // Skip updateElement/updateElementAwareness — they are set below as - // composed versions that include both React state updates and DOM updates. - if (key === "updateElement" || key === "updateElementAwareness") continue; + // Skip render callbacks. Composed versions below include both React + // state updates and built-in DOM updates. + if ( + key === "update" || + key === "updateElement" || + key === "updateElementAwareness" + ) continue; // @ts-ignore element[key] = value; } // @ts-ignore + delete element.update; + // @ts-ignore element.updateElement = updateElement; // @ts-ignore element.updateElementAwareness = updateElementAwareness; @@ -433,6 +454,8 @@ export function CanPlayElement({ const renderedChildren = children({ // @ts-ignore data, + live, + users, awareness, awarenessByStableId, setData: (newData: T | ((draft: T) => void)) => { @@ -460,10 +483,13 @@ export function CanPlayElement({ } handler.setData(newData); }, - setMyAwareness: (newLocalAwareness) => { - getRegisteredHandler()?.setMyAwareness(newLocalAwareness); + setLive: (newLive) => { + getRegisteredHandler()?.setLive(newLive); + }, + setMyAwareness: (newLive) => { + getRegisteredHandler()?.setLive(newLive); }, - myAwareness, + myAwareness: live, ref, }); diff --git a/packages/react/src/utils.tsx b/packages/react/src/utils.tsx index 10d0e51e..8cc47b9f 100644 --- a/packages/react/src/utils.tsx +++ b/packages/react/src/utils.tsx @@ -23,12 +23,16 @@ export interface PlayableChildren { export type ReactElementInitializer = Omit< ElementInitializer, | "updateElement" + | "update" | "defaultData" | "defaultLocalData" | "myDefaultAwareness" + | "live" | "updateElementAwareness" > & { defaultData: T | ((element: HTMLElement) => T); + live?: V | ((element: HTMLElement) => V); + /** @deprecated Use `live`. */ myDefaultAwareness?: V | ((element: HTMLElement) => V); id?: string; } & PlayableChildren; diff --git a/templates/html-starter/index.html b/templates/html-starter/index.html index 6b40dfc3..e863b11f 100644 --- a/templates/html-starter/index.html +++ b/templates/html-starter/index.html @@ -238,11 +238,13 @@

people here

// from the @click handler in the template. `register` works before or // after init(), and accepts an element or its id. The view renderer is // experimental; registration is a supported API for both view and - // imperative updateElement renderers. + // imperative update renderers. playhtml.register("reactionBtn", { defaultData: { count: 0 }, - view: ({ data, setData }) => { + live: { hovering: false }, + view: ({ data, users, setData, setLive }) => { const hasReacted = Boolean(localStorage.getItem("reacted-reaction")); + const peopleHovering = users.filter(({ live }) => live.hovering).length; const react = () => { if (localStorage.getItem("reacted-reaction")) { setData((d) => { d.count -= 1; }); @@ -257,8 +259,10 @@

people here

class="reaction ${hasReacted ? "reacted" : ""}" style="font-size: 24px; padding: 10px 20px; margin: 10px 0;" @click=${react} + @pointerenter=${() => setLive({ hovering: true })} + @pointerleave=${() => setLive({ hovering: false })} > - 💖 ${data.count} + 💖 ${data.count} · ${peopleHovering} here `; }, diff --git a/templates/react-starter/src/App.tsx b/templates/react-starter/src/App.tsx index 95e8a5ca..3b5e7047 100644 --- a/templates/react-starter/src/App.tsx +++ b/templates/react-starter/src/App.tsx @@ -89,8 +89,8 @@ function PeopleHere() { // withSharedState is the React API for custom collaborative elements. // Vanilla HTML uses playhtml.register(elementOrId, initializer) for the same role. const ReactionButton = withSharedState( - { defaultData: { count: 0 } }, - ({ data, setData, ref }) => { + { defaultData: { count: 0 }, live: { hovering: false } }, + ({ data, users, setData, setLive, ref }) => { const [hasReacted, setHasReacted] = useState(false); useEffect(() => { @@ -102,6 +102,8 @@ const ReactionButton = withSharedState( return ( ); }