
Shopify Pos Ui
- 6.3k installs
- 476 repo stars
- Updated July 27, 2026
- shopify/shopify-ai-toolkit
shopify-pos-ui is a Shopify AI toolkit skill for POS UI extensions with CLI scaffolding, doc search, and validated Preact component code.
About
The shopify-pos-ui skill helps developers build retail point-of-sale applications with Shopify POS UI components and extension targets. Agents search POS UI docs with scripts/search_docs.mjs, generate type-safe Preact extension code, then validate via scripts/validate.mjs including a required --target flag such as pos.customer-details.block.render. CLI scaffolding is mandatory: never hand-create app structure or configuration files. The documented flow installs Shopify CLI, initializes or enters an app directory, and generates POS UI extensions through CLI commands rather than manual folder creation. Validation requires base64-encoded user prompts, session metadata when available, artifact ids, and revision numbers with up to three retry cycles. Extension targets map UI blocks to POS surfaces like customer details, smart grid tiles, and checkout-adjacent flows. Use when users ask for POS retail UI, smart grid extensions, or Shopify CLI POS scaffolding with production-ready Preact patterns verified against the latest pos-ui framework version.
- Mandatory search_docs.mjs and validate.mjs with --target for every POS UI response.
- CLI-only scaffolding: never manually create POS extension app structure or config files.
- Preact POS UI extension code with type-safe component patterns and extension targets.
- Documented Shopify CLI flow from app init through extension generation inside the app directory.
- Validation retries up to three times with artifact id and revision tracking per code block.
Shopify Pos Ui by the numbers
- 6,309 all-time installs (skills.sh)
- +267 installs in the week ending Jul 28, 2026 (Skillselion tracking)
- Ranked #26 of 1,048 Mobile Development skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Jul 28, 2026 (Skillselion catalog sync)
shopify-pos-ui capabilities & compatibility
- Capabilities
- cli mandated pos extension scaffolding · doc search before codegen · target aware validation workflow · preact pos ui component patterns · retry loop with artifact revisions
- Use cases
- frontend · api development
What shopify-pos-ui says it does
Build retail point-of-sale applications using Shopify's POS UI components.
ALWAYS USE THE CLI TO SCAFFOLD A NEW EXTENSION AND NEVER MANUALLY CREATE THE APP STRUCTURE
npx skills add https://github.com/shopify/shopify-ai-toolkit --skill shopify-pos-uiAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 6.3k |
|---|---|
| repo stars | ★ 476 |
| Security audit | 1 / 3 scanners passed |
| Last updated | July 27, 2026 |
| Repository | shopify/shopify-ai-toolkit ↗ |
How do I scaffold and implement Shopify POS UI extensions with correct targets, CLI structure, and validated Preact snippets?
Build Shopify POS UI extensions with CLI scaffolding, Preact components, extension targets, and validated pos-ui framework code.
Who is it for?
Retail developers building Shopify POS UI extensions, smart grid tiles, and customer-facing POS blocks with Shopify CLI.
Skip if: Skip for Hydrogen storefront work, Admin API scripts, or manual extension folders without Shopify CLI scaffolding.
When should I use this skill?
User mentions POS UI extensions, retail POS smart grid, Shopify CLI POS scaffolding, or pos-ui component targets.
What you get
CLI-scaffolded POS extension code that passes validate.mjs with the correct --target and pos-ui framework patterns.
- POS UI extension components
- Validated extension source code
By the numbers
- shopify-ai-toolkit shopify-pos-ui skill version 1.9.0
- Mandates three-step workflow: search_docs.mjs, write code, validate.mjs
Files
Required Tool Calls (do not skip)
You have a bash tool. Every response must use it — in this order:
1. Call bash with scripts/search_docs.mjs "<query>" — search before writing code 2. Write the code using the search results 3. Call bash with scripts/validate.mjs --code '...' --model YOUR_MODEL_NAME --client-name YOUR_CLIENT_NAME --client-version YOUR_CLIENT_VERSION --artifact-id YOUR_ARTIFACT_ID --revision REVISION_NUMBER --target <extension-target> — validate before returning (Always include these flags. Use your actual model name for YOUR_MODEL_NAME; use claude-code/cursor/etc. for YOUR_CLIENT_NAME. For YOUR_ARTIFACT_ID, generate a stable random ID per code block and reuse it across validation retries. For REVISION_NUMBER, start at 1 and increment on each retry of the same artifact.) Pass --target with the point-of-sale extension target this code runs in (e.g. pos.customer-details.block.render); validation will fail without it. 4. If validation fails: search for the error type, fix, re-validate (max 3 retries) 5. Return code only after validation passes
You must run both search_docs.mjs and validate.mjs in every response. Do not return code to the user without completing step 3.
---
You are an assistant that helps Shopify developers write UI Framework code to interact with the latest Shopify pos-ui UI Framework version.
You should find all operations that can help the developer achieve their goal, provide valid UI Framework code along with helpful explanations.<system-instructions> You are an expert Shopify POS UI Extensions developer generating production-ready, type-safe Preact code that extends POS functionality while maintaining performance, security, and user experience standards. All code examples in this document are illustrative only. ALWAYS verify actual API documentation before using any method, component, or property
🚨 MANDATORY: ALWAYS USE THE CLI TO SCAFFOLD A NEW EXTENSION AND NEVER MANUALLY CREATE THE APP STRUCTURE OR CONFIGURATION FILES. ALWAYS use CLI to scaffold new extensions. NEVER manually create app structure or configuration files. If any CLI command fails (non-zero exit code) or environment is non-interactive, STOP, print the exact command, and instruct the user to run it locally.
Create POS UI extension flow
<pos-extension-todo-flow> <step id="1"> Ensure Shopify CLI is installed and up to date. For installation or upgrade steps, use shopify-use-shopify-cli. </step> <step id="2"> Determine if working with new app or existing app <step id="2.1"> If existing app: <step id="2.1.1">cd into the app directory</step> </step> <step id="2.2"> If no existing app: <step id="2.2.1">Run shopify app init --template=none --name={{appropriate-app-name}}</step> <step id="2.2.2">cd into the app directory</step> </step> <step id="2.3"> <step id="2.3.1">Ignore all existing extensions in the app. Only generate new extension. DO NOT modify existing extensions.</step> <step id="2.3.2">Run shopify app generate extension --name="{{appropriate-extension-name}}" --template="{{appropriate-template|default-pos_smart_grid}}" (template options: pos_action|pos_block|pos_smart_grid) ⚠️ --yes is NOT a flag. DO NOT use it. Run the command as is.</step> </step> </step> </pos-extension-todo-flow> </system-instructions>
If no extension target is specified, search the documentation to determine the appropriate target for the user's use case before generating code.
Available Extension Targets for pos-ui
Surface: point-of-sale Total Targets:
{
"admin": [
{
"name": "unstable"
},
{
"name": "2026-07",
"releaseCandidate": true
},
{
"name": "2026-04",
"latestVersion": true
},
{
"name": "2026-01"
},
{
"name": "2025-10"
},
{
"name": "2025-07"
}
],
"storefront-graphql": [
{
"name": "unstable"
},
{
"name": "2026-07",
"releaseCandidate": true
},
{
"name": "2026-04",
"latestVersion": true
},
{
"name": "2026-01"
},
{
"name": "2025-10"
},
{
"name": "2025-07"
}
],
"partner": [
{
"name": "unstable"
},
{
"name": "2026-07",
"releaseCandidate": true
},
{
"name": "2026-04",
"latestVersion": true
},
{
"name": "2026-01"
},
{
"name": "2025-10"
},
{
"name": "2025-07"
}
],
"customer": [
{
"name": "unstable"
},
{
"name": "2026-07",
"releaseCandidate": true
},
{
"name": "2026-04",
"latestVersion": true
},
{
"name": "2026-01"
},
{
"name": "2025-10"
},
{
"name": "2025-07"
}
],
"payments-apps": [
{
"name": "unstable"
},
{
"name": "2026-07",
"releaseCandidate": true
},
{
"name": "2026-04",
"latestVersion": true
},
{
"name": "2026-01"
},
{
"name": "2025-10"
},
{
"name": "2025-07"
}
],
"polaris-app-home": [],
"polaris-admin-extensions": [
{
"name": "2026-04",
"releaseCandidate": true
},
{
"name": "2026-01",
"latestVersion": true
},
{
"name": "2025-10"
},
{
"name": "2025-07"
}
],
"polaris-checkout-extensions": [
{
"name": "2026-07",
"releaseCandidate": true
},
{
"name": "2026-04",
"latestVersion": true
},
{
"name": "2026-01"
},
{
"name": "2025-10"
},
{
"name": "2025-07"
}
],
"polaris-customer-account-extensions": [
{
"name": "2026-07",
"releaseCandidate": true
},
{
"name": "2026-04",
"latestVersion": true
},
{
"name": "2026-01"
},
{
"name": "2025-10"
},
{
"name": "2025-07"
}
],
"pos-ui": [
{
"name": "2026-07",
"releaseCandidate": true
},
{
"name": "2026-04",
"latestVersion": true
},
{
"name": "2026-01"
},
{
"name": "2025-10"
},
{
"name": "2025-07"
}
],
"hydrogen": [
{
"name": "2026-04",
"latestVersion": true
},
{
"name": "2026-01"
},
{
"name": "2025-10"
},
{
"name": "2025-07"
}
],
"storefront-web-components": [],
"functions_cart_checkout_validation": [
{
"name": "unstable"
},
{
"name": "2026-07",
"releaseCandidate": true
},
{
"name": "2026-04",
"latestVersion": true
},
{
"name": "2026-01"
},
{
"name": "2025-10"
},
{
"name": "2025-07"
}
],
"functions_cart_transform": [
{
"name": "unstable"
},
{
"name": "2026-07",
"releaseCandidate": true
},
{
"name": "2026-04",
"latestVersion": true
},
{
"name": "2026-01"
},
{
"name": "2025-10"
},
{
"name": "2025-07"
}
],
"functions_delivery_customization": [
{
"name": "unstable"
},
{
"name": "2026-07",
"releaseCandidate": true
},
{
"name": "2026-04",
"latestVersion": true
},
{
"name": "2026-01"
},
{
"name": "2025-10"
},
{
"name": "2025-07"
}
],
"functions_discount": [
{
"name": "unstable"
},
{
"name": "2026-07",
"releaseCandidate": true
},
{
"name": "2026-04",
"latestVersion": true
},
{
"name": "2026-01"
},
{
"name": "2025-10"
},
{
"name": "2025-07"
}
],
"functions_discounts_allocator": [
{
"name": "unstable",
"latestVersion": true
}
],
"functions_fulfillment_constraints": [
{
"name": "unstable"
},
{
"name": "2026-07",
"releaseCandidate": true
},
{
"name": "2026-04",
"latestVersion": true
},
{
"name": "2026-01"
},
{
"name": "2025-10"
},
{
"name": "2025-07"
}
],
"functions_local_pickup_delivery_option_generator": [
{
"name": "unstable",
"latestVersion": true
}
],
"functions_order_discounts": [
{
"name": "unstable"
},
{
"name": "2026-07",
"releaseCandidate": true
},
{
"name": "2026-04",
"latestVersion": true
},
{
"name": "2026-01"
},
{
"name": "2025-10"
},
{
"name": "2025-07"
}
],
"functions_order_routing_location_rule": [
{
"name": "unstable"
},
{
"name": "2026-07",
"releaseCandidate": true
},
{
"name": "2026-04",
"latestVersion": true
},
{
"name": "2026-01"
},
{
"name": "2025-10"
},
{
"name": "2025-07"
}
],
"functions_payment_customization": [
{
"name": "unstable"
},
{
"name": "2026-07",
"releaseCandidate": true
},
{
"name": "2026-04",
"latestVersion": true
},
{
"name": "2026-01"
},
{
"name": "2025-10"
},
{
"name": "2025-07"
}
],
"functions_pickup_point_delivery_option_generator": [
{
"name": "unstable",
"latestVersion": true
}
],
"functions_product_discounts": [
{
"name": "unstable"
},
{
"name": "2026-07",
"releaseCandidate": true
},
{
"name": "2026-04",
"latestVersion": true
},
{
"name": "2026-01"
},
{
"name": "2025-10"
},
{
"name": "2025-07"
}
],
"functions_shipping_discounts": [
{
"name": "unstable"
},
{
"name": "2026-07",
"releaseCandidate": true
},
{
"name": "2026-04",
"latestVersion": true
},
{
"name": "2026-01"
},
{
"name": "2025-10"
},
{
"name": "2025-07"
}
]
}
import {createPackage, Runtime} from '@shopify/loom';
import {defaultProjectPlugin} from '../../config/loom';
export default createPackage((pkg) => {
pkg.runtimes(Runtime.Node, Runtime.Browser);
pkg.entry({root: './src/index.ts'});
pkg.use(defaultProjectPlugin());
});
{
"name": "@remote-ui/async-subscription",
"version": "2.1.18",
"publishConfig": {
"access": "public",
"@remote-ui:registry": "https://registry.npmjs.org"
},
"sideEffects": false,
"main": "index.js",
"module": "index.mjs",
"esnext": "index.esnext",
"types": "./build/ts/index.d.ts",
"exports": {
".": {
"types": "./build/ts/index.d.ts",
"esnext": "./index.esnext",
"import": "./index.mjs",
"require": "./index.js"
}
},
"license": "MIT",
"dependencies": {
"@remote-ui/rpc": "^1.4.7"
}
}
import {retain, release} from '@remote-ui/rpc';
import type {SyncSubscribable, RemoteSubscribable} from './types';
export function createRemoteSubscribable<T>(
subscription: SyncSubscribable<T>,
): RemoteSubscribable<T> {
const initial = subscription.current;
return {
initial,
subscribe(subscriber) {
retain(subscriber);
const unsubscribe = subscription.subscribe(
(value = subscription.current) => {
return subscriber(value);
},
);
const teardown = () => {
unsubscribe();
release(subscriber);
};
return [teardown, subscription.current];
},
};
}
export {makeStatefulSubscribable} from './stateful';
export {createRemoteSubscribable} from './create';
export type {
SyncSubscribable,
RemoteSubscribable,
StatefulRemoteSubscribable,
} from './types';
import {retain, release} from '@remote-ui/rpc';
import type {
Subscriber,
RemoteSubscribable,
RemoteSubscribeResult,
StatefulRemoteSubscribable,
} from './types';
export function makeStatefulSubscribable<T>(
subscription: RemoteSubscribable<T>,
): StatefulRemoteSubscribable<T> {
// We retain because it will automatically retain any functions we get from
// calling functions on this object, which will automatically manage the memory
// for unsubscribe callbacks received from subscription.subscribe().
retain(subscription);
let current = subscription.initial;
let listening = true;
let hasUpdated = false;
const subscribers = new Set<Subscriber<T>>();
const subscriptionResult = Promise.resolve<RemoteSubscribeResult<T>>(
subscription.subscribe(listener),
).then((result) => {
// Because of the async nature of receiving the result, we may have
// already started receiving updates from the subscriber before this
// code has been reached. In that case, we do not want to apply the
// value we received on subscribing, because it is already out of date.
if (!hasUpdated) listener(result[1]);
return result;
});
return {
get current() {
return current;
},
subscribe(subscriber) {
subscribers.add(subscriber);
return () => {
subscribers.delete(subscriber);
};
},
async destroy() {
listening = false;
subscribers.clear();
const [unsubscribe] = await subscriptionResult;
unsubscribe();
release(subscription);
},
};
function listener(value: T) {
hasUpdated = true;
if (!listening || current === value) return;
current = value;
for (const subscriber of subscribers) {
subscriber(current);
}
}
}
import type {MaybePromise} from '@remote-ui/rpc';
export type Subscriber<T> = (value: T) => unknown;
export type RemoteSubscribeResult<T> = [() => void, T];
export interface SyncSubscribable<T> {
readonly current: T;
subscribe(subscriber: Subscriber<T>): () => void;
}
export interface RemoteSubscribable<T> {
readonly initial: T;
subscribe(subscriber: Subscriber<T>): MaybePromise<RemoteSubscribeResult<T>>;
}
export interface StatefulRemoteSubscribable<T> extends SyncSubscribable<T> {
destroy(): Promise<void>;
}
import {createPackage, Runtime} from '@shopify/loom';
import {defaultProjectPlugin} from '../../config/loom';
export default createPackage((pkg) => {
pkg.runtimes(Runtime.Node, Runtime.Browser);
pkg.entry({root: './src/index.ts'});
pkg.use(defaultProjectPlugin());
});
{
"name": "@remote-ui/core",
"version": "2.2.7",
"publishConfig": {
"access": "public",
"@remote-ui:registry": "https://registry.npmjs.org"
},
"license": "MIT",
"main": "index.js",
"module": "index.mjs",
"esnext": "index.esnext",
"types": "./build/ts/index.d.ts",
"exports": {
".": {
"types": "./build/ts/index.d.ts",
"esnext": "./index.esnext",
"import": "./index.mjs",
"require": "./index.js"
}
},
"sideEffects": false,
"dependencies": {
"@remote-ui/rpc": "^1.4.7",
"@remote-ui/types": "^1.1.3"
}
}
import {RemoteComponentType} from '@remote-ui/types';
export function createRemoteComponent<
Type extends string,
Props = Record<string, never>,
AllowedChildren extends RemoteComponentType<string, any> | boolean = true,
>(
componentType: Type,
): Type & RemoteComponentType<Type, Props, AllowedChildren> {
return componentType as any;
}
export type {
RemoteComponentType,
PropsForRemoteComponent,
AllowedChildrenForRemoteComponent,
IdentifierForRemoteComponent,
} from '@remote-ui/types';
export {retain, release} from '@remote-ui/rpc';
export {createRemoteComponent} from './component';
export {
ACTION_MOUNT,
ACTION_INSERT_CHILD,
ACTION_REMOVE_CHILD,
ACTION_UPDATE_PROPS,
ACTION_UPDATE_TEXT,
KIND_ROOT,
KIND_COMPONENT,
KIND_TEXT,
KIND_FRAGMENT,
} from './types';
export type {
RemoteRoot,
RemoteChannel,
RemoteComponent,
RemoteText,
RemoteChild,
RemoteFragment,
Serialized,
ActionArgumentMap,
RemoteTextSerialization,
RemoteComponentSerialization,
RemoteFragmentSerialization,
} from './types';
export {createRemoteRoot} from './root';
export {
createRemoteReceiver,
createRemoteChannel,
isRemoteFragmentSerialization,
isRemoteReceiverAttachableFragment,
} from './receiver';
export type {
RemoteReceiver,
RemoteReceiverAttachment,
RemoteReceiverAttachable,
RemoteReceiverAttachableChild,
RemoteReceiverAttachableRoot,
RemoteReceiverAttachableComponent,
RemoteReceiverAttachableText,
RemoteReceiverAttachableFragment,
} from './receiver';
export {isRemoteComponent, isRemoteText, isRemoteFragment} from './utilities';
import {retain, release} from '@remote-ui/rpc';
import {
ACTION_MOUNT,
ACTION_INSERT_CHILD,
ACTION_REMOVE_CHILD,
ACTION_UPDATE_PROPS,
ACTION_UPDATE_TEXT,
KIND_COMPONENT,
KIND_FRAGMENT,
KIND_ROOT,
} from './types';
import type {
ActionArgumentMap,
RemoteChannel,
RemoteTextSerialization,
RemoteComponentSerialization,
RemoteFragmentSerialization,
} from './types';
import {isRemoteFragment} from './utilities';
export const ROOT_ID = Symbol('RootId');
export interface RemoteReceiverAttachableText extends RemoteTextSerialization {
version: number;
}
export interface RemoteReceiverAttachableComponent
extends Omit<RemoteComponentSerialization<any>, 'children'> {
children: RemoteReceiverAttachableChild[];
version: number;
}
export interface RemoteReceiverAttachableFragment
extends Omit<RemoteFragmentSerialization, 'children'> {
children: RemoteReceiverAttachableChild[];
version: number;
}
export interface RemoteReceiverAttachableRoot {
id: typeof ROOT_ID;
kind: typeof KIND_ROOT;
children: RemoteReceiverAttachableChild[];
version: number;
}
export type RemoteReceiverAttachableChild =
| RemoteReceiverAttachableText
| RemoteReceiverAttachableComponent;
export type RemoteReceiverAttachable =
| RemoteReceiverAttachableChild
| RemoteReceiverAttachableRoot
| RemoteReceiverAttachableFragment;
interface RemoteChannelRunner {
mount(...args: ActionArgumentMap[typeof ACTION_MOUNT]): void;
insertChild(...args: ActionArgumentMap[typeof ACTION_INSERT_CHILD]): void;
removeChild(...args: ActionArgumentMap[typeof ACTION_INSERT_CHILD]): void;
updateProps(...args: ActionArgumentMap[typeof ACTION_UPDATE_PROPS]): void;
updateText(...args: ActionArgumentMap[typeof ACTION_UPDATE_TEXT]): void;
}
export function createRemoteChannel({
mount,
insertChild,
removeChild,
updateProps,
updateText,
}: RemoteChannelRunner): RemoteChannel {
const messageMap = new Map<keyof ActionArgumentMap, (...args: any[]) => any>([
[ACTION_MOUNT, mount],
[ACTION_REMOVE_CHILD, removeChild],
[ACTION_INSERT_CHILD, insertChild],
[ACTION_UPDATE_PROPS, updateProps],
[ACTION_UPDATE_TEXT, updateText],
]);
return (type, ...args) => messageMap.get(type)!(...args);
}
export interface RemoteReceiverAttachment {
readonly root: RemoteReceiverAttachableRoot;
get<T extends RemoteReceiverAttachable>(attachable: Pick<T, 'id'>): T | null;
subscribe<T extends RemoteReceiverAttachable>(
{id}: T,
subscriber: (value: T) => void,
): () => void;
}
export interface RemoteReceiver {
readonly receive: RemoteChannel;
readonly attached: RemoteReceiverAttachment;
readonly state: 'mounted' | 'unmounted';
on(event: 'mount', handler: () => void): () => void;
flush(): Promise<void>;
}
export function createRemoteReceiver(): RemoteReceiver {
const queuedUpdates = new Set<RemoteReceiverAttachable>();
const listeners = new Map<
Parameters<RemoteReceiver['on']>[0],
Set<Parameters<RemoteReceiver['on']>[1]>
>();
const attachmentSubscribers = new Map<
string | typeof ROOT_ID,
Set<(value: RemoteReceiverAttachable) => void>
>();
let timeout: Promise<void> | null = null;
let state: RemoteReceiver['state'] = 'unmounted';
const root: RemoteReceiverAttachableRoot = {
id: ROOT_ID,
kind: KIND_ROOT,
children: [],
version: 0,
};
const attachedNodes = new Map<
string | typeof ROOT_ID,
RemoteReceiverAttachable
>([[ROOT_ID, root]]);
const receive = createRemoteChannel({
mount: (children) => {
const root = attachedNodes.get(ROOT_ID) as RemoteReceiverAttachableRoot;
const normalizedChildren = children.map((child) =>
normalizeNode(child, addVersion),
);
root.version += 1;
root.children = normalizedChildren;
state = 'mounted';
for (const child of normalizedChildren) {
retain(child);
attach(child);
}
// eslint-disable-next-line promise/catch-or-return
enqueueUpdate(root).then(() => {
emit('mount');
});
},
insertChild: (id, index, child, existingId) => {
const attached = attachedNodes.get(
id ?? ROOT_ID,
) as RemoteReceiverAttachableRoot;
const {children} = attached;
let existingAttached: RemoteReceiverAttachableRoot | undefined;
let normalizedChild: RemoteReceiverAttachableChild;
if (id === existingId) {
existingAttached = attached;
} else if (existingId !== false) {
existingAttached = attachedNodes.get(
existingId ?? ROOT_ID,
) as RemoteReceiverAttachableRoot;
}
if (existingAttached) {
const childId = child.id;
const existingChildren = existingAttached.children;
const existingIndex = existingChildren.findIndex(
(child) => child.id === childId,
);
const [removed] = existingChildren.splice(existingIndex, 1);
normalizedChild = removed;
// If we are just moving the child to a different index in the same node,
// we don’t need to enqueue an update, because that will be done for this
// node below.
if (id !== existingId) {
existingAttached.version += 1;
enqueueUpdate(existingAttached);
}
} else {
normalizedChild = normalizeNode(child, addVersion);
retain(normalizedChild);
attach(normalizedChild);
}
if (index === children.length) {
children.push(normalizedChild);
} else {
children.splice(index, 0, normalizedChild);
}
attached.version += 1;
enqueueUpdate(attached);
},
removeChild: (id, index) => {
const attached = attachedNodes.get(
id ?? ROOT_ID,
) as RemoteReceiverAttachableRoot;
const {children} = attached;
const [removed] = children.splice(index, 1);
if (!removed) {
return;
}
attached.version += 1;
detach(removed);
// eslint-disable-next-line promise/catch-or-return
enqueueUpdate(attached).then(() => {
release(removed);
});
},
updateProps: (id, newProps) => {
const component = attachedNodes.get(
id,
) as RemoteReceiverAttachableComponent;
const oldProps = {...(component.props as any)};
retain(newProps);
Object.keys(newProps).forEach((key) => {
const newProp = (newProps as any)[key];
const oldProp = (oldProps as any)[key];
if (isRemoteReceiverAttachableFragment(oldProp)) {
detach(oldProp);
}
if (isRemoteFragmentSerialization(newProp)) {
const attachableNewProp = normalizeNode(newProp, addVersion);
attach(attachableNewProp);
}
});
Object.assign(component.props as any, newProps);
component.version += 1;
// eslint-disable-next-line promise/catch-or-return
enqueueUpdate(component).then(() => {
for (const key of Object.keys(newProps)) {
release((oldProps as any)[key]);
}
});
},
updateText: (id, newText) => {
const text = attachedNodes.get(id) as RemoteReceiverAttachableText;
text.text = newText;
text.version += 1;
enqueueUpdate(text);
},
});
return {
get state() {
return state;
},
receive,
attached: {
root,
get({id}) {
return (attachedNodes.get(id) as any) ?? null;
},
subscribe({id}, subscriber) {
let subscribers = attachmentSubscribers.get(id);
if (subscribers == null) {
subscribers = new Set();
attachmentSubscribers.set(id, subscribers);
}
subscribers.add(subscriber as any);
return () => {
const subscribers = attachmentSubscribers.get(id);
if (subscribers) {
subscribers.delete(subscriber as any);
if (subscribers.size === 0) {
attachmentSubscribers.delete(id);
}
}
};
},
},
flush,
on(event, listener) {
let listenersForEvent = listeners.get(event);
if (listenersForEvent == null) {
listenersForEvent = new Set();
listeners.set(event, listenersForEvent);
}
listenersForEvent.add(listener);
return () => {
const listenersForEvent = listeners.get(event);
if (listenersForEvent) {
listenersForEvent.delete(listener);
if (listenersForEvent.size === 0) {
listeners.delete(event);
}
}
};
},
};
function flush() {
return timeout ?? Promise.resolve();
}
function emit(event: 'mount') {
const listenersForEvent = listeners.get(event);
if (listenersForEvent) {
for (const listener of listenersForEvent) {
listener();
}
}
}
function enqueueUpdate(attached: RemoteReceiverAttachable) {
timeout =
timeout ??
new Promise((resolve) => {
setTimeout(() => {
const attachedToUpdate = [...queuedUpdates];
timeout = null;
queuedUpdates.clear();
for (const attached of attachedToUpdate) {
const subscribers = attachmentSubscribers.get(attached.id);
if (subscribers) {
for (const subscriber of subscribers) {
subscriber(attached);
}
}
}
resolve();
}, 0);
});
queuedUpdates.add(attached);
return timeout;
}
function attach(
child: RemoteReceiverAttachableChild | RemoteReceiverAttachableFragment,
) {
attachedNodes.set(child.id, child);
if (child.kind === KIND_COMPONENT && 'props' in child) {
const {props = {}} = child as any;
Object.keys(props).forEach((key) => {
const prop = props[key];
if (!isRemoteReceiverAttachableFragment(prop)) return;
attach(prop);
});
}
if ('children' in child) {
for (const grandChild of child.children) {
attach(grandChild);
}
}
}
function detach(
child: RemoteReceiverAttachableChild | RemoteReceiverAttachableFragment,
) {
attachedNodes.delete(child.id);
if (child.kind === KIND_COMPONENT && 'props' in child) {
const {props = {}} = child as any;
Object.keys(props).forEach((key) => {
const prop = props[key];
if (!isRemoteReceiverAttachableFragment(prop)) return;
detach(prop);
});
}
if ('children' in child) {
for (const grandChild of child.children) {
detach(grandChild);
}
}
}
}
function addVersion<T>(
value: T,
): T extends RemoteTextSerialization
? RemoteReceiverAttachableText
: T extends RemoteComponentSerialization
? RemoteReceiverAttachableChild
: T extends RemoteFragmentSerialization
? RemoteReceiverAttachableFragment
: never {
(value as any).version = 0;
return value as any;
}
function normalizeNode<
T extends
| RemoteTextSerialization
| RemoteComponentSerialization
| RemoteFragmentSerialization,
R,
>(node: T, normalizer: (node: T) => R) {
if (node.kind === KIND_FRAGMENT || node.kind === KIND_COMPONENT) {
(node as any).children.forEach((child: T) =>
normalizeNode(child, normalizer),
);
}
if (node.kind === KIND_COMPONENT && 'props' in node) {
const {props} = node as any;
for (const key of Object.keys(props)) {
const prop = props[key];
if (!isRemoteFragmentSerialization(prop)) continue;
props[key] = normalizeNode(prop as any, normalizer);
}
}
return normalizer(node);
}
export function isRemoteFragmentSerialization(
object: unknown,
): object is RemoteFragmentSerialization {
return isRemoteFragment(object) && 'id' in object && 'children' in object;
}
export function isRemoteReceiverAttachableFragment(
object: unknown,
): object is RemoteReceiverAttachableFragment {
return isRemoteFragmentSerialization(object) && 'version' in object;
}
import type {RemoteComponentType} from '@remote-ui/types';
import {isBasicObject} from '@remote-ui/rpc';
import {
ACTION_MOUNT,
ACTION_INSERT_CHILD,
ACTION_REMOVE_CHILD,
ACTION_UPDATE_PROPS,
ACTION_UPDATE_TEXT,
KIND_ROOT,
KIND_COMPONENT,
KIND_TEXT,
KIND_FRAGMENT,
} from './types';
import type {
Serialized,
RemoteRoot,
RemoteText,
RemoteChannel,
RemoteComponent,
RemoteFragment,
RemoteRootOptions,
RemoteFragmentSerialization,
} from './types';
import {isRemoteFragment} from './utilities';
type AnyChild = RemoteText<any> | RemoteComponent<any, any>;
type AnyNode = AnyChild | RemoteFragment<any>;
type AnyParent =
| RemoteRoot<any, any>
| RemoteComponent<any, any>
| RemoteFragment<any>;
interface RootInternals {
strict: boolean;
mounted: boolean;
channel: RemoteChannel;
nodes: WeakSet<AnyNode>;
tops: WeakMap<AnyNode, AnyParent>;
parents: WeakMap<AnyNode, AnyParent>;
components: WeakMap<RemoteComponent<any, any>, ComponentInternals>;
fragments: WeakMap<RemoteFragment<any>, FragmentInternals>;
children: ReadonlyArray<AnyChild>;
}
interface ComponentInternals {
externalProps: {readonly [key: string]: any};
internalProps: {readonly [key: string]: any};
children: ReadonlyArray<AnyChild>;
}
interface FragmentInternals {
children: ReadonlyArray<AnyChild>;
}
type ParentInternals = RootInternals | ComponentInternals | FragmentInternals;
interface TextInternals {
text: string;
}
const FUNCTION_CURRENT_IMPLEMENTATION_KEY = '__current';
const EMPTY_OBJECT = {} as any;
const EMPTY_ARRAY: any[] = [];
type HotSwappableFunction<T extends (...args: any[]) => any> = T & {
[FUNCTION_CURRENT_IMPLEMENTATION_KEY]: any;
};
export function createRemoteRoot<
AllowedComponents extends RemoteComponentType<
string,
any
> = RemoteComponentType<any, any>,
AllowedChildrenTypes extends AllowedComponents | boolean = true,
>(
channel: RemoteChannel,
{strict = true, components}: RemoteRootOptions<AllowedComponents> = {},
): RemoteRoot<AllowedComponents, AllowedChildrenTypes> {
type Root = RemoteRoot<AllowedComponents, AllowedChildrenTypes>;
let currentId = 0;
const rootInternals: RootInternals = {
strict,
mounted: false,
channel,
children: EMPTY_ARRAY,
nodes: new WeakSet(),
parents: new WeakMap(),
tops: new WeakMap(),
components: new WeakMap(),
fragments: new WeakMap(),
};
if (strict) Object.freeze(components);
const remoteRoot: Root = {
kind: KIND_ROOT,
options: strict
? Object.freeze({strict, components})
: {strict, components},
get children() {
return rootInternals.children as any;
},
createComponent(type, ...rest) {
if (components && components.indexOf(type) < 0) {
throw new Error(`Unsupported component: ${type}`);
}
const [initialProps, initialChildren, ...moreChildren] = rest;
const normalizedInitialProps = initialProps ?? {};
const normalizedInitialChildren: AnyChild[] = [];
const normalizedInternalProps: {[key: string]: any} = {};
if (initialProps) {
for (const key of Object.keys(initialProps)) {
// "children" as a prop can be extremely confusing with the "children" of
// a component. In React, a "child" can be anything, but once it reaches
// a host environment (like this remote `Root`), we want "children" to have
// only one meaning: the actual, resolved children components and text.
//
// To enforce this, we delete any prop named "children". We don’t take a copy
// of the props for performance, so a user calling this function must do so
// with an object that can handle being mutated.
if (key === 'children') continue;
normalizedInternalProps[key] = makeValueHotSwappable(
serializeProp(initialProps[key]),
);
}
}
if (initialChildren) {
if (Array.isArray(initialChildren)) {
for (const child of initialChildren) {
normalizedInitialChildren.push(normalizeChild(child, remoteRoot));
}
} else {
normalizedInitialChildren.push(
normalizeChild(initialChildren, remoteRoot),
);
// The complex tuple type of `rest` makes it so `moreChildren` is
// incorrectly inferred as potentially being the props of the component,
// lazy casting since we know it will be an array of child elements
// (or empty).
for (const child of moreChildren as any[]) {
normalizedInitialChildren.push(normalizeChild(child, remoteRoot));
}
}
}
const id = `${currentId++}`;
const internals: ComponentInternals = {
externalProps: strict
? Object.freeze(normalizedInitialProps)
: normalizedInitialProps,
internalProps: normalizedInternalProps,
children: strict
? Object.freeze(normalizedInitialChildren)
: normalizedInitialChildren,
};
const component: RemoteComponent<AllowedComponents, Root> = {
kind: KIND_COMPONENT,
get children() {
return internals.children;
},
get props() {
return internals.externalProps;
},
get remoteProps() {
return internals.internalProps;
},
remove: () => remove(component),
updateProps: (newProps) =>
updateProps(component, newProps, internals, rootInternals),
append: (...children) =>
append(
component,
children.map((child) => normalizeChild(child, remoteRoot)),
internals,
rootInternals,
),
appendChild: (child) =>
appendChild(
component,
normalizeChild(child, remoteRoot),
internals,
rootInternals,
),
removeChild: (child) =>
removeChild(component, child, internals, rootInternals),
replaceChildren: (...children) =>
replaceChildren(
component,
children.map((child) => normalizeChild(child, remoteRoot)),
internals,
rootInternals,
),
insertBefore: (child, before) =>
insertBefore(
component,
normalizeChild(child, remoteRoot),
before,
internals,
rootInternals,
),
insertChildBefore: (child, before) =>
insertBefore(
component,
normalizeChild(child, remoteRoot),
before,
internals,
rootInternals,
),
// Just satisfying the type definition, since we need to write
// some properties manually, which we do below. If we just `as any`
// the whole object, we lose the implicit argument types for the
// methods above.
...EMPTY_OBJECT,
};
rootInternals.components.set(component, internals);
Object.defineProperty(component, 'type', {
value: type,
configurable: false,
writable: false,
enumerable: true,
});
makePartOfTree(component, rootInternals);
makeRemote(component, id, remoteRoot);
for (const child of internals.children) {
moveNodeToContainer(component, child, rootInternals);
}
return component;
},
createText(content = '') {
const id = `${currentId++}`;
const internals: TextInternals = {text: content};
const update: RemoteText<Root>['update'] = (newText) =>
updateText(text, newText, internals, rootInternals);
const text: RemoteText<Root> = {
kind: KIND_TEXT,
get text() {
return internals.text;
},
update,
updateText: update,
remove: () => remove(text),
// Just satisfying the type definition, since we need to write
// some properties manually.
...EMPTY_OBJECT,
};
makePartOfTree(text, rootInternals);
makeRemote(text, id, remoteRoot);
return text;
},
createFragment() {
const id = `${currentId++}`;
const internals: FragmentInternals = {
children: strict ? Object.freeze([]) : [],
};
const fragment: RemoteFragment<Root> = {
kind: KIND_FRAGMENT,
get children() {
return internals.children;
},
append: (...children) =>
append(
fragment,
children.map((child) => normalizeChild(child, remoteRoot)),
internals,
rootInternals,
),
appendChild: (child) =>
appendChild(
fragment,
normalizeChild(child, remoteRoot),
internals,
rootInternals,
),
removeChild: (child) =>
removeChild(fragment, child, internals, rootInternals),
replaceChildren: (...children) =>
replaceChildren(
fragment,
children.map((child) => normalizeChild(child, remoteRoot)),
internals,
rootInternals,
),
insertBefore: (child, before) =>
insertBefore(
fragment,
normalizeChild(child, remoteRoot),
before,
internals,
rootInternals,
),
insertChildBefore: (child, before) =>
insertBefore(
fragment,
normalizeChild(child, remoteRoot),
before,
internals,
rootInternals,
),
// Just satisfying the type definition, since we need to write
// some properties manually.
...EMPTY_OBJECT,
};
rootInternals.fragments.set(fragment, internals);
makePartOfTree(fragment, rootInternals);
makeRemote(fragment, id, remoteRoot);
return fragment;
},
append: (...children) =>
append(
remoteRoot,
children.map((child) => normalizeChild(child, remoteRoot)),
rootInternals,
rootInternals,
),
appendChild: (child) =>
appendChild(
remoteRoot,
normalizeChild(child, remoteRoot),
rootInternals,
rootInternals,
),
replaceChildren: (...children) =>
replaceChildren(
remoteRoot,
children.map((child) => normalizeChild(child, remoteRoot)),
rootInternals,
rootInternals,
),
removeChild: (child) =>
removeChild(remoteRoot, child, rootInternals, rootInternals),
insertBefore: (child, before) =>
insertBefore(
remoteRoot,
normalizeChild(child, remoteRoot),
before,
rootInternals,
rootInternals,
),
insertChildBefore: (child, before) =>
insertBefore(
remoteRoot,
normalizeChild(child, remoteRoot),
before,
rootInternals,
rootInternals,
),
mount() {
if (rootInternals.mounted) return Promise.resolve();
rootInternals.mounted = true;
return Promise.resolve(
channel(ACTION_MOUNT, rootInternals.children.map(serializeChild)),
);
},
};
return remoteRoot;
}
function connected(element: AnyNode, {tops}: RootInternals) {
return tops.get(element)?.kind === KIND_ROOT;
}
function allDescendants(element: AnyNode, withEach: (item: AnyNode) => void) {
const recurse = (element: AnyNode) => {
if ('children' in element) {
for (const child of element.children) {
withEach(child);
recurse(child);
}
}
};
recurse(element);
}
function perform(
element: AnyChild | AnyParent,
rootInternals: RootInternals,
{
remote,
local,
}: {
remote(channel: RemoteChannel): void | Promise<void>;
local(): void;
},
) {
const {mounted, channel} = rootInternals;
if (
mounted &&
(element.kind === KIND_ROOT || connected(element, rootInternals))
) {
// should only create context once async queue is cleared
remote(channel);
// technically, we should be waiting for the remote update to apply,
// then apply it locally. The implementation below is too naive because
// it allows local updates to get out of sync with remote ones.
// if (remoteResult == null || !('then' in remoteResult)) {
// local();
// return;
// } else {
// return remoteResult.then(() => {
// local();
// });
// }
}
local();
}
function updateText(
text: RemoteText<any>,
newText: string,
internals: TextInternals,
rootInternals: RootInternals,
) {
return perform(text, rootInternals, {
remote: (channel) => channel(ACTION_UPDATE_TEXT, text.id, newText),
local: () => {
internals.text = newText;
},
});
}
type HotSwapRecord = readonly [HotSwappableFunction<any>, any];
const IGNORE = Symbol('ignore');
function updateProps(
component: RemoteComponent<any, any>,
newProps: any,
internals: ComponentInternals,
rootInternals: RootInternals,
) {
const {strict} = rootInternals;
const {internalProps: currentProps, externalProps: currentExternalProps} =
internals;
const normalizedNewProps: {[key: string]: any} = {};
const hotSwapFunctions: HotSwapRecord[] = [];
let hasRemoteChange = false;
for (const key of Object.keys(newProps)) {
// See notes above for why we treat `children` as a reserved prop.
if (key === 'children') continue;
const currentExternalValue = currentExternalProps[key];
const newExternalValue = newProps[key];
const currentValue = currentProps[key];
const newValue = serializeProp(newExternalValue);
// Bail out if we have equal, primitive types
if (
currentValue === newValue &&
(newValue == null || typeof newValue !== 'object')
) {
continue;
}
const [value, hotSwaps] = tryHotSwappingValues(currentValue, newValue);
if (hotSwaps) {
hotSwapFunctions.push(...hotSwaps);
}
if (value === IGNORE) continue;
hasRemoteChange = true;
normalizedNewProps[key] = value;
if (isRemoteFragment(currentExternalValue)) {
removeNodeFromContainer(currentExternalValue, rootInternals);
}
if (isRemoteFragment(newExternalValue)) {
moveNodeToContainer(component, newExternalValue, rootInternals);
}
}
return perform(component, rootInternals, {
remote: (channel) => {
if (hasRemoteChange) {
channel(ACTION_UPDATE_PROPS, component.id, normalizedNewProps);
}
},
local: () => {
const mergedExternalProps = {
...currentExternalProps,
...newProps,
};
internals.externalProps = strict
? Object.freeze(mergedExternalProps)
: mergedExternalProps;
internals.internalProps = {
...internals.internalProps,
...normalizedNewProps,
};
for (const [hotSwappable, newValue] of hotSwapFunctions) {
hotSwappable[FUNCTION_CURRENT_IMPLEMENTATION_KEY] = newValue;
}
},
});
}
// Imagine the following remote-ui components we might render in a remote context:
//
// const root = createRemoteRoot();
// const {value, onChange, onPress} = getPropsForValue();
//
// const textField = root.createComponent('TextField', {value, onChange});
// const button = root.createComponent('Button', {onPress});
//
// root.append(textField);
// root.append(button);
//
// function getPropsForValue(value = '') {
// return {
// value,
// onChange: () => {
// const {value, onChange, onPress} = getPropsForValue();
// textField.updateProps({value, onChange});
// button.updateProps({onPress});
// },
// onPress: () => console.log(value),
// };
// }
//
//
// In this example, assume that the `TextField` `onChange` prop is run on blur.
// If this were running on the host, the following steps would happen if you pressed
// on the button:
//
// 1. The text field blurs, and so calls `onChange()` with its current value, which
// then calls `setValue()` with the updated value.
// 2. We synchronously update the `value`, `onChange`, and `onPress` props to point at
// the most current `value`.
// 3. Handling blur is finished, so the browser now handles the click by calling the
// (newly-updated) `Button` `onPress()`, which logs out the new value.
//
// Because remote-ui reproduces a UI tree asynchronously from the remote context, the
// steps above run in a different order:
//
// 1. The text field blurs, and so calls `onChange()` with its current value.
// 2. Handling blur is finished **from the perspective of the main thread**, so the
// browser now handles the click by calling the (original) `Button` `onPress()`, which
// logs out the **initial** value.
// 3. In the remote context, we receive the `onChange()` call, which calls updates the props
// on the `Button` and `TextField` to be based on the new `value`, but by now it’s
// already too late for `onPress` — the old version has already been called!
//
// As you can see, the timing issue introduced by the asynchronous nature of remote-ui
// can cause “old props” to be called from the main thread. This example may seem like
// an unusual pattern, and it is if you are using `@remote-ui/core` directly; you’d generally
// keep a mutable reference to the state, instead of closing over the state with new props.
// However, abstractions on top of `@remote-ui/core`, like the React reconciler in
// `@remote-ui/react`, work almost entirely by closing over state, so this issue is
// much more common with those declarative libraries.
//
// To protect against this, we handle function props a bit differently. When we have a
// function prop, we replace it with a new function that calls the original. However,
// we make the original mutable, by making it a property on the function itself. When
// this function subsequently updates, we don’t send the update to the main thread (as
// we just saw, this can often be "too late" to be of any use). Instead, we swap out
// the mutable reference to the current implementation of the function prop, which can
// be done synchronously. In the example above, this would all happen synchronously in
// the remote context; in our handling of `TextField onChange()`, we update `Button onPress()`,
// and swap out the implementations. Now, when the main thread attempts to call `Button onPress()`,
// it instead calls our wrapper around the function, which can refer to, and call, the
// most recently-applied implementation, instead of directly calling the old implementation.
type HotSwapResult = [any, HotSwapRecord[]?];
function tryHotSwappingValues(
currentValue: unknown,
newValue: unknown,
seen = new Set<any>(),
): HotSwapResult {
if (seen.has(currentValue)) {
return [IGNORE];
}
if (
typeof currentValue === 'function' &&
FUNCTION_CURRENT_IMPLEMENTATION_KEY in currentValue
) {
seen.add(currentValue);
const result: HotSwapResult = [
typeof newValue === 'function' ? IGNORE : makeValueHotSwappable(newValue),
[[currentValue as HotSwappableFunction<any>, newValue]],
];
return result;
}
if (Array.isArray(currentValue)) {
seen.add(currentValue);
const result = tryHotSwappingArrayValues(currentValue, newValue, seen);
return result;
}
if (isBasicObject(currentValue) && !isRemoteFragment(currentValue)) {
seen.add(currentValue);
const result = tryHotSwappingObjectValues(currentValue, newValue, seen);
return result;
}
const result: HotSwapResult = [currentValue === newValue ? IGNORE : newValue];
return result;
}
function makeValueHotSwappable(
value: unknown,
seen = new Map<any, any>(),
): unknown {
const seenValue = seen.get(value);
if (seenValue) return seenValue;
if (isRemoteFragment(value)) {
seen.set(value, value);
return value;
}
if (Array.isArray(value)) {
const result: any[] = [];
seen.set(value, result);
for (const nested of value) {
result.push(makeValueHotSwappable(nested, seen));
}
return result;
}
if (isBasicObject(value)) {
const result: Record<string, any> = {};
seen.set(value, result);
for (const key of Object.keys(value)) {
result[key] = makeValueHotSwappable((value as any)[key], seen);
}
return result;
}
if (typeof value === 'function') {
const wrappedFunction: HotSwappableFunction<any> = ((...args: any[]) => {
return wrappedFunction[FUNCTION_CURRENT_IMPLEMENTATION_KEY](...args);
}) as any;
Object.defineProperty(
wrappedFunction,
FUNCTION_CURRENT_IMPLEMENTATION_KEY,
{
enumerable: false,
configurable: false,
writable: true,
value,
},
);
seen.set(value, wrappedFunction);
return wrappedFunction;
}
return value;
}
function collectNestedHotSwappableValues(
value: unknown,
seen: Set<any> = new Set(),
): HotSwappableFunction<any>[] | undefined {
if (seen.has(value)) return undefined;
seen.add(value);
if (Array.isArray(value)) {
return value.reduce<HotSwappableFunction<any>[]>((all, element) => {
const nested = collectNestedHotSwappableValues(element, seen);
return nested ? [...all, ...nested] : all;
}, []);
}
if (isBasicObject(value)) {
return Object.keys(value).reduce<HotSwappableFunction<any>[]>(
(all, key) => {
const nested = collectNestedHotSwappableValues(
(value as any)[key],
seen,
);
return nested ? [...all, ...nested] : all;
},
[],
);
}
if (typeof value === 'function') {
return FUNCTION_CURRENT_IMPLEMENTATION_KEY in value ? [value] : undefined;
}
return undefined;
}
function remove(child: AnyChild) {
(child.parent as AnyParent)?.removeChild(child);
}
function append(
container: AnyParent,
children: AnyChild[],
internals: ParentInternals,
rootInternals: RootInternals,
) {
for (const child of children) {
appendChild(container, child, internals, rootInternals);
}
}
function appendChild(
container: AnyParent,
child: AnyChild,
internals: ParentInternals,
rootInternals: RootInternals,
) {
const {nodes, strict} = rootInternals;
if (!nodes.has(child)) {
throw new Error(
`Cannot append a node that was not created by this remote root`,
);
}
const currentParent = child.parent;
const existingIndex = currentParent?.children.indexOf(child) ?? -1;
return perform(container, rootInternals, {
remote: (channel) => {
channel(
ACTION_INSERT_CHILD,
(container as any).id,
existingIndex < 0
? container.children.length
: container.children.length - 1,
serializeChild(child),
currentParent ? currentParent.id : false,
);
},
local: () => {
moveNodeToContainer(container, child, rootInternals);
let newChildren: AnyChild[];
if (currentParent) {
const currentInternals = getCurrentInternals(
currentParent,
rootInternals,
)!;
const currentChildren = [...currentInternals.children];
currentChildren.splice(existingIndex, 1);
if (currentParent === container) {
newChildren = currentChildren;
} else {
currentInternals.children = strict
? Object.freeze(currentChildren)
: currentChildren;
newChildren = [...internals.children];
}
} else {
newChildren = [...internals.children];
}
newChildren.push(child);
internals.children = strict ? Object.freeze(newChildren) : newChildren;
},
});
}
function replaceChildren(
container: AnyParent,
children: AnyChild[],
internals: ParentInternals,
rootInternals: RootInternals,
) {
for (const child of container.children) {
removeChild(container, child, internals, rootInternals);
}
append(container, children, internals, rootInternals);
}
// there is a problem with this, because when multiple children
// are removed, there is no guarantee the messages will arrive in the
// order we need them to on the host side (it depends how React
// calls our reconciler). If it calls with, for example, the removal of
// the second last item, then the removal of the last item, it will fail
// because the indexes moved around.
//
// Might need to send the removed child ID, or find out if we
// can collect removals into a single update.
function removeChild(
container: AnyParent,
child: AnyChild,
internals: ParentInternals,
rootInternals: RootInternals,
) {
const {strict} = rootInternals;
const childIndex = container.children.indexOf(child as any);
if (childIndex === -1) {
return undefined;
}
return perform(container, rootInternals, {
remote: (channel) =>
channel(ACTION_REMOVE_CHILD, (container as any).id, childIndex),
local: () => {
removeNodeFromContainer(child, rootInternals);
const newChildren = [...internals.children];
newChildren.splice(newChildren.indexOf(child), 1);
internals.children = strict ? Object.freeze(newChildren) : newChildren;
},
});
}
function insertBefore(
container: AnyParent,
child: AnyChild,
before: AnyChild | undefined | null,
internals: ParentInternals,
rootInternals: RootInternals,
) {
const {strict, nodes} = rootInternals;
if (!nodes.has(child)) {
throw new Error(
`Cannot insert a node that was not created by this remote root`,
);
}
const currentParent = child.parent;
const existingIndex = currentParent?.children.indexOf(child) ?? -1;
return perform(container, rootInternals, {
remote: (channel) => {
const beforeIndex =
before == null
? container.children.length - 1
: container.children.indexOf(before as any);
channel(
ACTION_INSERT_CHILD,
(container as any).id,
beforeIndex < existingIndex || existingIndex < 0
? beforeIndex
: beforeIndex - 1,
serializeChild(child),
currentParent ? currentParent.id : false,
);
},
local: () => {
moveNodeToContainer(container, child, rootInternals);
let newChildren: AnyChild[];
if (currentParent) {
const currentInternals = getCurrentInternals(
currentParent,
rootInternals,
)!;
const currentChildren = [...currentInternals.children];
currentChildren.splice(existingIndex, 1);
if (currentParent === container) {
newChildren = currentChildren;
} else {
currentInternals.children = strict
? Object.freeze(currentChildren)
: currentChildren;
newChildren = [...internals.children];
}
} else {
newChildren = [...internals.children];
}
if (before == null) {
newChildren.push(child);
} else {
newChildren.splice(newChildren.indexOf(before), 0, child);
}
internals.children = strict ? Object.freeze(newChildren) : newChildren;
},
});
}
function normalizeChild(
child: AnyChild | string,
root: RemoteRoot<any, any>,
): AnyChild {
return typeof child === 'string' ? root.createText(child) : child;
}
function moveNodeToContainer(
container: AnyParent,
node: AnyNode,
rootInternals: RootInternals,
) {
const {tops, parents} = rootInternals;
const newTop =
container.kind === KIND_ROOT ? container : tops.get(container)!;
tops.set(node, newTop);
parents.set(node, container);
moveFragmentToContainer(node, rootInternals);
allDescendants(node, (descendant) => {
tops.set(descendant, newTop);
moveFragmentToContainer(descendant, rootInternals);
});
}
function moveFragmentToContainer(node: AnyNode, rootInternals: RootInternals) {
if (node.kind !== KIND_COMPONENT) return;
const props = node.props as any;
if (!props) return;
Object.values(props).forEach((prop) => {
if (!isRemoteFragment(prop)) return;
moveNodeToContainer(node, prop, rootInternals);
});
}
function removeNodeFromContainer(node: AnyNode, rootInternals: RootInternals) {
const {tops, parents} = rootInternals;
tops.delete(node);
parents.delete(node);
allDescendants(node, (descendant) => {
tops.delete(descendant);
removeFragmentFromContainer(descendant, rootInternals);
});
removeFragmentFromContainer(node, rootInternals);
}
function removeFragmentFromContainer(
node: AnyNode,
rootInternals: RootInternals,
) {
if (node.kind !== KIND_COMPONENT) return;
const props = node.remoteProps as any;
for (const key of Object.keys(props ?? {})) {
const prop = props[key];
if (!isRemoteFragment(prop)) continue;
removeNodeFromContainer(prop, rootInternals);
}
}
function makePartOfTree(node: AnyNode, {parents, tops, nodes}: RootInternals) {
nodes.add(node);
Object.defineProperty(node, 'parent', {
get() {
return parents.get(node);
},
configurable: true,
enumerable: true,
});
Object.defineProperty(node, 'top', {
get() {
return tops.get(node);
},
configurable: true,
enumerable: true,
});
}
function serializeChild(value: AnyChild): Serialized<typeof value> {
return value.kind === KIND_TEXT
? {id: value.id, kind: value.kind, text: value.text}
: {
id: value.id,
kind: value.kind,
type: value.type,
props: value.remoteProps,
children: value.children.map((child) => serializeChild(child)),
};
}
function serializeProp(prop: any) {
if (isRemoteFragment(prop)) {
return serializeFragment(prop);
}
return prop;
}
function serializeFragment(
value: RemoteFragment<any>,
): RemoteFragmentSerialization {
return {
id: value.id,
kind: value.kind,
get children() {
return value.children.map((child) => serializeChild(child));
},
};
}
function getCurrentInternals(
currentParent: AnyChild['parent'],
rootInternals: RootInternals,
): ParentInternals | undefined {
if (currentParent.kind === KIND_ROOT) {
return rootInternals;
}
if (currentParent.kind === KIND_FRAGMENT) {
return rootInternals.fragments.get(currentParent);
}
return rootInternals.components.get(currentParent);
}
function makeRemote<Root extends RemoteRoot<any, any>>(
value: RemoteText<Root> | RemoteComponent<any, Root> | RemoteFragment<Root>,
id: string,
root: Root,
) {
Object.defineProperty(value, 'id', {
value: id,
configurable: true,
writable: false,
enumerable: false,
});
Object.defineProperty(value, 'root', {
value: root,
configurable: true,
writable: false,
enumerable: false,
});
}
function tryHotSwappingObjectValues(
currentValue: object,
newValue: unknown,
seen: Set<any>,
): HotSwapResult {
if (!isBasicObject(newValue)) {
return [
makeValueHotSwappable(newValue),
collectNestedHotSwappableValues(currentValue)?.map(
(hotSwappable) => [hotSwappable, undefined] as const,
),
];
}
let hasChanged = false;
const hotSwaps: HotSwapRecord[] = [];
const normalizedNewValue: {[key: string]: any} = {};
// eslint-disable-next-line guard-for-in
for (const key in currentValue) {
const currentObjectValue = (currentValue as any)[key];
if (!(key in newValue)) {
hasChanged = true;
const nestedHotSwappables =
collectNestedHotSwappableValues(currentObjectValue);
if (nestedHotSwappables) {
hotSwaps.push(
...nestedHotSwappables.map(
(hotSwappable) => [hotSwappable, undefined] as const,
),
);
}
}
const newObjectValue = (newValue as any)[key];
const [updatedValue, elementHotSwaps] = tryHotSwappingValues(
currentObjectValue,
newObjectValue,
seen,
);
if (elementHotSwaps) {
hotSwaps.push(...elementHotSwaps);
}
if (updatedValue !== IGNORE) {
hasChanged = true;
normalizedNewValue[key] = updatedValue;
}
}
for (const key in newValue) {
if (key in normalizedNewValue) continue;
hasChanged = true;
normalizedNewValue[key] = makeValueHotSwappable((newValue as any)[key]);
}
return [hasChanged ? normalizedNewValue : IGNORE, hotSwaps];
}
function tryHotSwappingArrayValues(
currentValue: unknown[],
newValue: unknown,
seen: Set<any>,
): HotSwapResult {
if (!Array.isArray(newValue)) {
return [
makeValueHotSwappable(newValue),
collectNestedHotSwappableValues(currentValue)?.map(
(hotSwappable) => [hotSwappable, undefined] as const,
),
];
}
let hasChanged = false;
const hotSwaps: HotSwapRecord[] = [];
const newLength = newValue.length;
const currentLength = currentValue.length;
const maxLength = Math.max(currentLength, newLength);
const normalizedNewValue: any[] = [];
for (let i = 0; i < maxLength; i++) {
const currentArrayValue = currentValue[i];
const newArrayValue = newValue[i];
if (i < newLength) {
if (i >= currentLength) {
hasChanged = true;
normalizedNewValue[i] = makeValueHotSwappable(newArrayValue);
continue;
}
const [updatedValue, elementHotSwaps] = tryHotSwappingValues(
currentArrayValue,
newArrayValue,
seen,
);
if (elementHotSwaps) hotSwaps.push(...elementHotSwaps);
if (updatedValue === IGNORE) {
normalizedNewValue[i] = currentArrayValue;
continue;
}
hasChanged = true;
normalizedNewValue[i] = updatedValue;
} else {
hasChanged = true;
const nestedHotSwappables =
collectNestedHotSwappableValues(currentArrayValue);
if (nestedHotSwappables) {
hotSwaps.push(
...nestedHotSwappables.map(
(hotSwappable) => [hotSwappable, undefined] as const,
),
);
}
}
}
return [hasChanged ? normalizedNewValue : IGNORE, hotSwaps];
}
import {
RemoteComponentType,
IdentifierForRemoteComponent,
PropsForRemoteComponent,
} from '@remote-ui/types';
type NonOptionalKeys<T> = {
[K in keyof T]-?: undefined extends T[K] ? never : K;
}[keyof T];
type IfAllOptionalKeys<Obj, If, Else = never> = Obj extends Record<
string,
never
>
? If
: NonOptionalKeys<Obj> extends {
length: 0;
}
? If
: Else;
export const ACTION_MOUNT = 0;
export const ACTION_INSERT_CHILD = 1;
export const ACTION_REMOVE_CHILD = 2;
export const ACTION_UPDATE_TEXT = 3;
export const ACTION_UPDATE_PROPS = 4;
export const UPDATE_INSERT = 0;
export const UPDATE_REMOVE = 1;
export const KIND_ROOT = 0;
export const KIND_COMPONENT = 1;
export const KIND_TEXT = 2;
export const KIND_FRAGMENT = 3;
export type Id = string;
export interface ActionArgumentMap {
[ACTION_MOUNT]: [(RemoteTextSerialization | RemoteComponentSerialization)[]];
[ACTION_INSERT_CHILD]: [
Id | undefined,
number,
RemoteTextSerialization | RemoteComponentSerialization,
Id | undefined | false,
];
[ACTION_REMOVE_CHILD]: [Id | undefined, number];
[ACTION_UPDATE_TEXT]: [Id, string];
[ACTION_UPDATE_PROPS]: [Id, Record<string, unknown>];
}
export interface RemoteChannel {
<T extends keyof ActionArgumentMap>(
type: T,
...payload: ActionArgumentMap[T]
): void | Promise<void>;
}
type AllowedRemoteChildren<
Children,
Root extends RemoteRoot<any, any>,
> = Children extends RemoteComponentType<string, any, any>
? RemoteComponent<Children, Root>
: never;
type ExtractChildren<Type> = Type extends RemoteComponentType<
string,
any,
infer Children
>
? Children
: never;
type AllowedChildren<
Children extends RemoteComponentType<string, any> | boolean,
Root extends RemoteRoot<any, any>,
AllowString extends boolean = false,
> = Children extends true
? RemoteComponent<any, Root> | AllowedTextChildren<Root, AllowString>
: Children extends false
? never
:
| AllowedRemoteChildren<Children, Root>
| AllowedTextChildren<Root, AllowString>;
type AllowedTextChildren<
Root extends RemoteRoot<any, any>,
AllowString extends boolean = false,
> = AllowString extends true ? RemoteText<Root> | string : RemoteText<Root>;
export interface RemoteRootOptions<
AllowedComponents extends RemoteComponentType<string, any>,
> {
readonly strict?: boolean;
readonly components?: ReadonlyArray<AllowedComponents>;
}
export interface RemoteRoot<
AllowedComponents extends RemoteComponentType<
string,
any
> = RemoteComponentType<any, any>,
AllowedChildrenTypes extends
| RemoteComponentType<string, any>
| boolean = true,
> {
readonly kind: typeof KIND_ROOT;
readonly children: ReadonlyArray<
AllowedChildren<
AllowedChildrenTypes,
RemoteRoot<AllowedComponents, AllowedChildrenTypes>
>
>;
readonly options: RemoteRootOptions<AllowedComponents>;
append(
...children: AllowedChildren<
AllowedChildrenTypes,
RemoteRoot<AllowedComponents, AllowedChildrenTypes>,
true
>[]
): void | Promise<void>;
/**
* @deprecated use `RemoteRoot.append` instead.
*/
appendChild(
child: AllowedChildren<
AllowedChildrenTypes,
RemoteRoot<AllowedComponents, AllowedChildrenTypes>,
true
>,
): void | Promise<void>;
removeChild(
child: AllowedChildren<
AllowedChildrenTypes,
RemoteRoot<AllowedComponents, AllowedChildrenTypes>
>,
): void | Promise<void>;
replaceChildren(
...children: AllowedChildren<
AllowedChildrenTypes,
RemoteRoot<AllowedComponents, AllowedChildrenTypes>,
true
>[]
): void | Promise<void>;
insertBefore(
child: AllowedChildren<
AllowedChildrenTypes,
RemoteRoot<AllowedComponents, AllowedChildrenTypes>
>,
before?: AllowedChildren<
AllowedChildrenTypes,
RemoteRoot<AllowedComponents, AllowedChildrenTypes>
> | null,
): void | Promise<void>;
/**
* @deprecated use `RemoteRoot.insertBefore` instead.
*/
insertChildBefore(
child: AllowedChildren<
AllowedChildrenTypes,
RemoteRoot<AllowedComponents, AllowedChildrenTypes>
>,
before: AllowedChildren<
AllowedChildrenTypes,
RemoteRoot<AllowedComponents, AllowedChildrenTypes>
>,
): void | Promise<void>;
createComponent<Type extends AllowedComponents>(
type: Type,
...rest: IfAllOptionalKeys<
PropsForRemoteComponent<Type>,
| [
(PropsForRemoteComponent<Type> | null)?,
...AllowedChildren<
AllowedChildrenTypes,
RemoteRoot<AllowedComponents, AllowedChildrenTypes>,
true
>[],
]
| [
(PropsForRemoteComponent<Type> | null)?,
AllowedChildren<
AllowedChildrenTypes,
RemoteRoot<AllowedComponents, AllowedChildrenTypes>,
true
>[]?,
],
| [
PropsForRemoteComponent<Type>,
...AllowedChildren<
AllowedChildrenTypes,
RemoteRoot<AllowedComponents, AllowedChildrenTypes>,
true
>[],
]
| [
PropsForRemoteComponent<Type>,
AllowedChildren<
AllowedChildrenTypes,
RemoteRoot<AllowedComponents, AllowedChildrenTypes>,
true
>[]?,
]
>
): RemoteComponent<Type, RemoteRoot<AllowedComponents, AllowedChildrenTypes>>;
createText(
text?: string,
): RemoteText<RemoteRoot<AllowedComponents, AllowedChildrenTypes>>;
createFragment(): RemoteFragment<
RemoteRoot<AllowedComponents, AllowedChildrenTypes>
>;
mount(): Promise<void>;
}
export interface RemoteComponent<
Type extends RemoteComponentType<string, any>,
Root extends RemoteRoot<any, any>,
> {
readonly kind: typeof KIND_COMPONENT;
readonly id: string;
readonly type: IdentifierForRemoteComponent<Type>;
readonly props: PropsForRemoteComponent<Type>;
readonly remoteProps: PropsForRemoteComponent<Type>;
readonly children: ReadonlyArray<
AllowedChildren<ExtractChildren<Type>, Root>
>;
readonly root: Root;
readonly top: RemoteComponent<any, Root> | Root | null;
readonly parent: RemoteComponent<any, Root> | Root | null;
remove(): void | Promise<void>;
updateProps(
props: Partial<PropsForRemoteComponent<Type>>,
): void | Promise<void>;
append(
...children: AllowedChildren<ExtractChildren<Type>, Root, true>[]
): void | Promise<void>;
/**
* @deprecated use `RemoteComponent.append` instead.
*/
appendChild(
child: AllowedChildren<ExtractChildren<Type>, Root, true>,
): void | Promise<void>;
removeChild(
child: AllowedChildren<ExtractChildren<Type>, Root>,
): void | Promise<void>;
replaceChildren(
...children: AllowedChildren<ExtractChildren<Type>, Root, true>[]
): void | Promise<void>;
insertBefore(
child: AllowedChildren<ExtractChildren<Type>, Root>,
before?: AllowedChildren<ExtractChildren<Type>, Root> | null,
): void | Promise<void>;
/**
* @deprecated use `RemoteComponent.insertBefore` instead.
*/
insertChildBefore(
child: AllowedChildren<ExtractChildren<Type>, Root>,
before: AllowedChildren<ExtractChildren<Type>, Root>,
): void | Promise<void>;
}
export interface RemoteFragment<
Root extends RemoteRoot<any, any> = RemoteRoot<any, any>,
> {
readonly kind: typeof KIND_FRAGMENT;
readonly id: string;
readonly children: ReadonlyArray<AllowedChildren<ExtractChildren<any>, Root>>;
readonly root: Root;
readonly top: RemoteComponent<any, Root> | Root | null;
readonly parent: RemoteComponent<any, Root> | Root | null;
append(
...children: AllowedChildren<ExtractChildren<any>, Root, true>[]
): void | Promise<void>;
/**
* @deprecated use `RemoteComponent.append` instead.
*/
appendChild(
child: AllowedChildren<ExtractChildren<any>, Root, true>,
): void | Promise<void>;
removeChild(
child: AllowedChildren<ExtractChildren<any>, Root>,
): void | Promise<void>;
replaceChildren(
...children: AllowedChildren<ExtractChildren<any>, Root, true>[]
): void | Promise<void>;
insertBefore(
child: AllowedChildren<ExtractChildren<any>, Root>,
before?: AllowedChildren<ExtractChildren<any>, Root> | null,
): void | Promise<void>;
/**
* @deprecated use `RemoteComponent.insertBefore` instead.
*/
insertChildBefore(
child: AllowedChildren<ExtractChildren<any>, Root>,
before: AllowedChildren<ExtractChildren<any>, Root>,
): void | Promise<void>;
}
export interface RemoteText<Root extends RemoteRoot<any, any>> {
readonly kind: typeof KIND_TEXT;
readonly id: string;
readonly text: string;
readonly root: Root;
readonly top: RemoteComponent<any, Root> | Root | null;
readonly parent: RemoteComponent<any, Root> | Root | null;
update(text: string): void | Promise<void>;
/**
* @deprecated use `RemoteText.update` instead.
*/
updateText(text: string): void | Promise<void>;
remove(): void | Promise<void>;
}
export type RemoteChild<Root extends RemoteRoot<any, any>> =
| RemoteComponent<any, Root>
| RemoteText<Root>;
export type RemoteComponentSerialization<
Type extends RemoteComponentType<string, any> = RemoteComponentType<
string,
any
>,
> = {
-readonly [K in 'id' | 'type' | 'kind' | 'props']: RemoteComponent<
Type,
any
>[K];
} & {
children: (RemoteComponentSerialization | RemoteTextSerialization)[];
};
export type RemoteTextSerialization = {
-readonly [K in 'id' | 'text' | 'kind']: RemoteText<any>[K];
};
export type RemoteFragmentSerialization = {
-readonly [K in 'id' | 'kind']: RemoteFragment<any>[K];
} & {
children: (RemoteComponentSerialization | RemoteTextSerialization)[];
};
export type Serialized<T> = T extends RemoteComponent<infer Type, any>
? RemoteComponentSerialization<Type>
: T extends RemoteText<any>
? RemoteTextSerialization
: never;
import type {RemoteComponentType} from '@remote-ui/types';
import type {
RemoteRoot,
RemoteComponent,
RemoteText,
RemoteFragment,
} from './types';
import {KIND_COMPONENT, KIND_TEXT, KIND_FRAGMENT} from './types';
export function isRemoteComponent<
Type extends RemoteComponentType<string, any, any> = any,
Root extends RemoteRoot<any, any> = RemoteRoot<any, any>,
>(child: unknown): child is RemoteComponent<Type, Root> {
return child != null && (child as any).kind === KIND_COMPONENT;
}
export function isRemoteText<
Root extends RemoteRoot<any, any> = RemoteRoot<any, any>,
>(child: unknown): child is RemoteText<Root> {
return child != null && (child as any).kind === KIND_TEXT;
}
export function isRemoteFragment<
Root extends RemoteRoot<any, any> = RemoteRoot<any, any>,
>(object: unknown): object is RemoteFragment<Root> {
return object != null && (object as any).kind === KIND_FRAGMENT;
}
import {createPackage, Runtime} from '@shopify/loom';
import {defaultProjectPlugin} from '../../config/loom';
export default createPackage((pkg) => {
pkg.runtimes(Runtime.Node, Runtime.Browser);
pkg.entry({root: './src/index.ts'});
pkg.entry({root: './src/host/index.ts', name: 'host'});
pkg.use(defaultProjectPlugin({react: true}));
});
{
"name": "@remote-ui/react",
"version": "5.0.8",
"publishConfig": {
"access": "public",
"@remote-ui:registry": "https://registry.npmjs.org"
},
"license": "MIT",
"main": "index.js",
"module": "index.mjs",
"esnext": "index.esnext",
"types": "./build/ts/index.d.ts",
"typesVersions": {
"*": {
"host": [
"./build/ts/host/index.d.ts"
]
}
},
"exports": {
".": {
"types": "./build/ts/index.d.ts",
"esnext": "./index.esnext",
"import": "./index.mjs",
"require": "./index.js"
},
"./host": {
"types": "./build/ts/host/index.d.ts",
"esnext": "./host.esnext",
"import": "./host.mjs",
"require": "./host.js"
}
},
"sideEffects": false,
"devDependencies": {
"@types/react-dom": "^18.0.11",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-reconciler": "^0.29.0"
},
"dependencies": {
"@remote-ui/async-subscription": "^2.1.18",
"@remote-ui/core": "^2.2.7",
"@remote-ui/rpc": "^1.4.7",
"@types/react": ">=17.0.0 <19.0.0",
"@types/react-reconciler": ">=0.26.0 <0.30.0"
},
"peerDependencies": {
"react": ">=17.0.0 <19.0.0",
"react-reconciler": ">=0.26.0 <0.30.0"
},
"peerDependenciesMeta": {
"react": {
"optional": false
},
"react-reconciler": {
"optional": true
}
}
}
import {createContext} from 'react';
import type {RemoteRoot} from '@remote-ui/core';
import type {Reconciler} from './reconciler';
export interface RenderContextDescriptor {
root: RemoteRoot;
reconciler: Reconciler;
}
export const RenderContext = createContext<RenderContextDescriptor | null>(
null,
);
export {useRemoteSubscription} from './subscription';
export {useRender} from './render';
import {useContext} from 'react';
import {RenderContext} from '../context';
export function useRender() {
const render = useContext(RenderContext);
if (render == null) {
throw new Error('No remote-ui Render instance found in context');
}
return render;
}
import {useState, useEffect, useDebugValue} from 'react';
import type {StatefulRemoteSubscribable} from '@remote-ui/async-subscription';
export function useRemoteSubscription<T>(
subscribable: StatefulRemoteSubscribable<T>,
) {
const [state, setState] = useState(() => ({
subscribable,
value: subscribable.current,
}));
let valueToReturn = state.value;
if (state.subscribable !== subscribable) {
valueToReturn = subscribable.current;
setState({subscribable, value: valueToReturn});
}
useDebugValue(valueToReturn);
useEffect(() => {
const unsubscribe = subscribable.subscribe(checkForUpdates);
checkForUpdates();
return () => {
unsubscribe();
};
function checkForUpdates() {
const value = subscribable.current;
setState((previousState) => {
if (
previousState.subscribable !== subscribable ||
previousState.value === value
) {
return previousState;
}
return {...previousState, value};
});
}
}, [subscribable]);
return valueToReturn;
}
import {useState, useDebugValue, useEffect} from 'react';
import type {RemoteReceiver, RemoteReceiverAttachable} from '@remote-ui/core';
interface State<T extends RemoteReceiverAttachable> {
receiver: RemoteReceiver;
id: RemoteReceiverAttachable['id'];
version?: RemoteReceiverAttachable['version'];
value: T | null;
}
export function useAttached<T extends RemoteReceiverAttachable>(
receiver: RemoteReceiver,
attached: T,
) {
const [state, setState] = useState<State<T>>({
receiver,
id: attached.id,
version: attached.version,
value: attached,
});
let returnValue: T | null = state.value;
// If parameters have changed since our last render, schedule an update with its current value.
if (state.receiver !== receiver || state.id !== attached.id) {
// When the consumer of this hook changes receiver or attached node, the node they switched
// to might already be unmounted. We guard against that by making sure we don’t get null
// back from the receiver, and storing the “attached” node in state whether it is actually
// attached or not, so we have a paper trail of how we got here.
const updated = receiver.attached.get<T>(attached);
// If the subscription has been updated, we'll schedule another update with React.
// React will process this update immediately, so the old subscription value won't be committed.
// It is still nice to avoid returning a mismatched value though, so let's override the return value.
returnValue = updated;
setState({
receiver,
id: attached.id,
version: attached.version,
value: returnValue,
});
}
useDebugValue(returnValue);
useEffect(() => {
let didUnsubscribe = false;
const checkForUpdates = () => {
if (didUnsubscribe) return;
setState((previousState) => {
const {
id: previousId,
version: previousVersion,
receiver: previousReceiver,
} = previousState;
const {id} = attached;
// Ignore values from stale sources
if (previousReceiver !== receiver || previousId !== id) {
return previousState;
}
// This function is also called as part of the initial useEffect() when the
// component mounts. It’s possible that between the initial render (when the
// remote component was for sure attached, to the best of the host’s knowledge)
// and the effect, the component was removed from the remote tree. You’ll see that
// the rest of this callback is careful to handle cases where the node is in this
// state.
const value = receiver.attached.get<T>(attached);
const version = value?.version;
// If the value hasn't changed, no update is needed.
// Return state as-is so React can bail out and avoid an unnecessary render.
if (previousVersion === version) {
return previousState;
}
return {receiver, value, id, version};
});
};
const unsubscribe = receiver.attached.subscribe(attached, checkForUpdates);
// Passive effect, so we need to check if anything has changed
checkForUpdates();
return () => {
didUnsubscribe = true;
unsubscribe();
};
}, [receiver, attached]);
return returnValue;
}
export type {RemoteReceiver} from '@remote-ui/core';
export {createRemoteReceiver} from '@remote-ui/core';
export {RemoteRenderer} from './RemoteRenderer';
export type {RemoteRendererProps} from './RemoteRenderer';
export {RemoteComponent} from './RemoteComponent';
export {RemoteText} from './RemoteText';
export {createController} from './controller';
export {useAttached} from './hooks';
export type {ComponentMapping, ControllerOptions} from './controller';
export type {
ReactPropsFromRemoteComponentType,
ReactComponentTypeFromRemoteComponentType,
} from '../types';
export type {Controller, RemoteComponentProps, RemoteTextProps} from './types';
import type {ComponentType, ReactNode} from 'react';
import type {
RemoteReceiver,
RemoteReceiverAttachableRoot,
RemoteReceiverAttachableComponent,
RemoteReceiverAttachableText,
RemoteComponentType,
RemoteReceiverAttachableFragment,
} from '@remote-ui/core';
export interface RemoteTextProps {
parent: RemoteReceiverAttachableRoot | RemoteReceiverAttachableComponent;
text: RemoteReceiverAttachableText;
receiver: RemoteReceiver;
key: string | number;
}
export interface RemoteComponentProps {
receiver: RemoteReceiver;
parent: RemoteReceiverAttachableRoot | RemoteReceiverAttachableComponent;
component: RemoteReceiverAttachableComponent;
controller: Controller;
key: string | number;
}
export interface RemoteFragmentProps {
receiver: RemoteReceiver;
parent: RemoteReceiverAttachableComponent;
fragment: RemoteReceiverAttachableFragment;
controller: Controller;
}
export interface Controller {
get(type: string | RemoteComponentType<string, any, any>): ComponentType<any>;
renderer: Renderer;
}
export interface Renderer {
renderComponent(props: RemoteComponentProps): ReactNode;
renderText(props: RemoteTextProps): ReactNode;
}
export interface RenderComponentOptions {
renderDefault(): ReactNode;
}
export interface RenderTextOptions {
renderDefault(): ReactNode;
}
export {createRemoteRoot} from '@remote-ui/core';
export type {RemoteRoot, RemoteReceiver} from '@remote-ui/core';
export {render, createRoot} from './render';
export type {Root} from './render';
export {createRemoteReactComponent} from './components';
export {useRemoteSubscription} from './hooks';
export type {
ReactPropsFromRemoteComponentType,
ReactComponentTypeFromRemoteComponentType,
} from './types';
export {createReconciler} from './reconciler';
export type {Reconciler} from './reconciler';
import reactReconciler from 'react-reconciler';
import type {Reconciler as ReactReconciler} from 'react-reconciler';
import type {
RemoteRoot,
RemoteText,
RemoteComponent,
RemoteComponentType,
} from '@remote-ui/core';
type Type = RemoteComponentType<string, any>;
type Props = Record<string, unknown>;
type ViewInstance = RemoteComponent<any, any>;
type TextInstance = RemoteText<any>;
type HostContext = Record<string, never>;
type UpdatePayload = Record<string, unknown>;
type SuspenseInstance = never;
type PublicInstance = unknown;
type HydratableInstance = unknown;
type ChildSet = unknown;
type TimeoutHandle = unknown;
type NoTimeout = unknown;
export type Reconciler = ReactReconciler<
RemoteRoot,
ViewInstance,
TextInstance,
SuspenseInstance,
PublicInstance
>;
export const createReconciler = (options?: {primary?: boolean}) =>
reactReconciler<
Type,
Props,
RemoteRoot,
ViewInstance,
TextInstance,
SuspenseInstance,
HydratableInstance,
PublicInstance,
HostContext,
UpdatePayload,
ChildSet,
TimeoutHandle,
NoTimeout
>({
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore - Compat for React <= 17.x
now: Date.now,
// Timeout
scheduleTimeout: setTimeout,
cancelTimeout: clearTimeout,
noTimeout: false,
// Microtask scheduling
// @see https://github.com/facebook/react/blob/2c8a1452b82b9ec5ebfa3f370b31fda19610ae92/packages/react-dom/src/client/ReactDOMHostConfig.js#L391-L401
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore - types in `@types/react-reconciler` are outdated
supportsMicrotasks: true,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore - types in `@types/react-reconciler` are outdated
scheduleMicrotask,
// Compat for React <= 17.x
queueMicrotask: scheduleMicrotask,
isPrimaryRenderer: options?.primary ?? true,
supportsMutation: true,
supportsHydration: false,
supportsPersistence: false,
// Context
getRootHostContext() {
return {};
},
getChildHostContext(context) {
return context;
},
// Instances
createTextInstance(text, root) {
return root.createText(text);
},
createInstance(type, allProps, root) {
const {children: _children, ...props} = allProps;
return root.createComponent(type, props);
},
// Updates
commitTextUpdate(text, _oldText, newText) {
text.update(newText);
},
prepareUpdate(_instance, _type, oldProps, newProps) {
const updateProps: Record<string, unknown> = {};
let needsUpdate = false;
for (const key in oldProps) {
if (!has(oldProps, key) || key === 'children') {
continue;
}
if (!(key in newProps)) {
needsUpdate = true;
updateProps[key] = undefined;
// } else if (typeof oldProps[key] === 'function') {
// if (typeof newProps[key] === 'function') {
// fragment.controller.functions.exchange(
// oldProps[key] as Function,
// newProps[key] as Function,
// );
// } else {
// needsUpdate = true;
// fragment.controller.functions.revoke(oldProps[key] as Function);
// updateProps[key] = newProps[key];
// }
} else if (oldProps[key] !== newProps[key]) {
needsUpdate = true;
updateProps[key] = newProps[key];
}
}
for (const key in newProps) {
if (!has(newProps, key) || key === 'children') {
continue;
}
if (!(key in oldProps)) {
needsUpdate = true;
updateProps[key] = newProps[key];
}
}
return needsUpdate ? updateProps : null;
},
commitUpdate(instance, payload) {
instance.updateProps(payload);
},
// Update root
appendChildToContainer(remoteRoot, child) {
remoteRoot.append(child);
},
insertInContainerBefore(remoteRoot, child, beforeChild) {
remoteRoot.insertBefore(child, beforeChild);
},
removeChildFromContainer(remoteRoot, child) {
remoteRoot.removeChild(child);
},
clearContainer(remoteRoot) {
for (const child of remoteRoot.children) {
remoteRoot.removeChild(child);
}
},
// Update children
appendInitialChild(parent, child) {
parent.append(child);
},
appendChild(parent, child) {
parent.append(child);
},
insertBefore(parent, newChild, beforeChild) {
parent.insertBefore(newChild, beforeChild);
},
removeChild(parent, child) {
parent.removeChild(child);
},
// Unknown
finalizeInitialChildren() {
return false;
},
shouldSetTextContent() {
return false;
},
getPublicInstance() {},
prepareForCommit() {
return null;
},
resetAfterCommit() {},
commitMount() {},
preparePortalMount() {},
detachDeletedInstance() {},
});
function scheduleMicrotask(callback: () => void) {
return typeof queueMicrotask === 'function'
? queueMicrotask
: Promise.resolve(null).then(callback).catch(handleErrorInNextTick);
}
function handleErrorInNextTick(error: Error) {
setTimeout(() => {
throw error;
});
}
const {hasOwnProperty} = {};
function has(object: object, property: string | number | symbol) {
return hasOwnProperty.call(object, property);
}
import type {ReactNode, ComponentType, ReactElement} from 'react';
import type {RemoteComponentType, RemoteFragment} from '@remote-ui/core';
type PropsForRemoteComponent<T> = T extends RemoteComponentType<
string,
infer Props,
any
>
? Props extends Record<string, never>
? // eslint-disable-next-line @typescript-eslint/ban-types
{}
: {[K in keyof Props]: RemoteFragmentToReactElement<Props[K]>}
: never;
type RemoteFragmentToReactElement<T> = T extends RemoteFragment<infer R>
? ReactElement | false | RemoteFragment<R>
: T;
/* eslint-disable @typescript-eslint/ban-types */
export type ReactPropsFromRemoteComponentType<
Type extends RemoteComponentType<string, any, any>,
> = PropsForRemoteComponent<Type> &
(Type extends RemoteComponentType<string, any, infer Children>
? false extends Children
? {}
: {
children?: ReactNode;
}
: {});
/* eslint-enable @typescript-eslint/ban-types */
export type ReactComponentTypeFromRemoteComponentType<
Type extends RemoteComponentType<string, any, any>,
> = ComponentType<ReactPropsFromRemoteComponentType<Type>>;
import {createPackage, Runtime} from '@shopify/loom';
import {defaultProjectPlugin} from '../../config/loom';
export default createPackage((pkg) => {
pkg.runtimes(Runtime.Node, Runtime.Browser);
pkg.entry({root: './src/index.ts'});
pkg.use(defaultProjectPlugin());
});
{
"name": "@remote-ui/rpc",
"description": "An RPC library with strong support for simulating the transfer of functions via postMessage",
"version": "1.4.7",
"publishConfig": {
"access": "public"
},
"license": "MIT",
"main": "index.js",
"module": "index.mjs",
"esnext": "index.esnext",
"types": "./build/ts/index.d.ts",
"exports": {
".": {
"types": "./build/ts/index.d.ts",
"esnext": "./index.esnext",
"import": "./index.mjs",
"require": "./index.js"
}
},
"sideEffects": false,
"devDependencies": {
"@types/uuid": "^8.0.0",
"uuid": "^8.2.0"
}
}
export const READY_MESSAGE_KEY = 'remote-ui::ready';
import type {MessageEndpoint} from '../types';
import {READY_MESSAGE_KEY} from './constants';
export function fromInsideIframe({targetOrigin = '*'} = {}): MessageEndpoint {
if (typeof self === 'undefined' || self.parent == null) {
throw new Error(
`This does not appear to be a child iframe, because there is no parent window.`,
);
}
// We wait until the document is ready before advertising to the parent that
// communication can commence.
// However, it's possible that the parent isn't listening to messages at this time.
// Which can lead to communication never starting.
// Therefore we also wait for the parent to send a message once it's ready to (re)send the
// ready message from within the child iframe.
const {parent} = self;
const ready = () => parent.postMessage(READY_MESSAGE_KEY, targetOrigin);
window.addEventListener('message', (event) => {
if (event.source !== parent || document.readyState !== 'complete') {
return;
}
if (event.data === READY_MESSAGE_KEY) {
ready();
}
});
if (document.readyState === 'complete') {
ready();
} else {
document.addEventListener('readystatechange', () => {
if (document.readyState === 'complete') {
ready();
}
});
}
// We need to store the listener, because we wrap it to do some origin checking. Ideally,
// we’d instead store an `AbortController`, and use its signal to cancel the listeners,
// but that isn’t widely supported.
const listenerMap = new WeakMap<
(event: MessageEvent) => void,
(event: MessageEvent) => void
>();
return {
postMessage(message, transfer) {
parent.postMessage(message, targetOrigin, transfer);
},
addEventListener(event, listener) {
const wrappedListener = (event: MessageEvent) => {
if (event.source !== parent) return;
listener(event);
};
listenerMap.set(listener, wrappedListener);
self.addEventListener(event, wrappedListener);
},
removeEventListener(event, listener) {
const wrappedListener = listenerMap.get(listener);
if (wrappedListener == null) return;
listenerMap.delete(listener);
self.removeEventListener(event, wrappedListener);
},
};
}
import type {MessageEndpoint} from '../types';
import {READY_MESSAGE_KEY} from './constants';
export function fromIframe(
target: HTMLIFrameElement,
{terminate: shouldTerminate = true, targetOrigin = '*'} = {},
): MessageEndpoint {
if (typeof window === 'undefined') {
throw new Error(
`You can only run fromIframe() in a browser context, but no window was found.`,
);
}
// We need to store the listener, because we wrap it to do some origin checking. Ideally,
// we’d instead store an `AbortController`, and use its signal to cancel the listeners,
// but that isn’t widely supported.
const listenerMap = new WeakMap<
(event: MessageEvent) => void,
(event: MessageEvent) => void
>();
let resolveIFrameReadyPromise: () => void;
function onMessage(event: MessageEvent<any>) {
if (event.source !== target.contentWindow) return;
if (event.data === READY_MESSAGE_KEY) {
window.removeEventListener('message', onMessage);
resolveIFrameReadyPromise();
}
}
target.contentWindow?.postMessage(READY_MESSAGE_KEY, targetOrigin);
const iframeReadyPromise = new Promise<void>((resolve) => {
resolveIFrameReadyPromise = resolve;
window.addEventListener('message', onMessage);
});
return {
async postMessage(message, transfer) {
await iframeReadyPromise;
target.contentWindow?.postMessage(message, targetOrigin, transfer);
},
addEventListener(event, listener) {
const wrappedListener = (event: MessageEvent) => {
if (event.source !== target.contentWindow) return;
listener(event);
};
listenerMap.set(listener, wrappedListener);
self.addEventListener(event, wrappedListener);
},
removeEventListener(event, listener) {
const wrappedListener = listenerMap.get(listener);
if (wrappedListener == null) return;
listenerMap.delete(listener);
self.removeEventListener(event, wrappedListener);
},
terminate() {
window.removeEventListener('message', onMessage);
if (shouldTerminate) target.remove();
},
};
}
export {fromIframe} from './iframe-parent';
export {fromInsideIframe} from './iframe-child';
export {fromMessagePort} from './message-port';
export {fromWebWorker} from './web-worker';
import type {MessageEndpoint} from '../types';
export function fromMessagePort(messagePort: MessagePort): MessageEndpoint {
return {
postMessage: (...args: [any, Transferable[]]) =>
messagePort.postMessage(...args),
addEventListener: (...args) => messagePort.addEventListener(...args),
removeEventListener: (...args) => messagePort.removeEventListener(...args),
terminate() {
messagePort.close();
},
};
}
import type {MessageEndpoint} from '../types';
export function fromWebWorker(worker: Worker): MessageEndpoint {
return worker;
}
import {
RETAINED_BY,
RETAIN_METHOD,
RELEASE_METHOD,
EncodingStrategy,
EncodingStrategyApi,
} from '../types';
import type {Retainer} from '../memory';
import {StackFrame, isBasicObject, isMemoryManageable} from '../memory';
type AnyFunction = (...args: any[]) => any;
const FUNCTION = '_@f';
export function createBasicEncoder(api: EncodingStrategyApi): EncodingStrategy {
const functionsToId = new Map<AnyFunction, string>();
const idsToFunction = new Map<string, AnyFunction>();
const idsToProxy = new Map<string, AnyFunction>();
return {
encode,
decode,
async call(id, args) {
const stackFrame = new StackFrame();
const func = idsToFunction.get(id);
if (func == null) {
throw new Error(
'You attempted to call a function that was already released.',
);
}
try {
const retainedBy = isMemoryManageable(func)
? [stackFrame, ...func[RETAINED_BY]]
: [stackFrame];
const result = await func(...(decode(args, retainedBy) as any[]));
return result;
} finally {
stackFrame.release();
}
},
release(id) {
const func = idsToFunction.get(id);
if (func) {
idsToFunction.delete(id);
functionsToId.delete(func);
}
},
terminate() {
functionsToId.clear();
idsToFunction.clear();
idsToProxy.clear();
},
};
type EncodeResult = [any, Transferable[]?];
function encode(
value: unknown,
seen = new Map<any, EncodeResult>(),
): EncodeResult {
if (value == null) {
return [value];
}
const seenValue = seen.get(value);
if (seenValue) {
return seenValue;
}
if (typeof value === 'object') {
if (Array.isArray(value)) {
seen.set(value, [undefined]);
const transferables: Transferable[] = [];
const result = value.map((item) => {
const [result, nestedTransferables = []] = encode(item, seen);
transferables.push(...nestedTransferables);
return result;
});
const fullResult: EncodeResult = [result, transferables];
seen.set(value, fullResult);
return fullResult;
}
if (isBasicObject(value)) {
seen.set(value, [undefined]);
const transferables: Transferable[] = [];
const result = Object.keys(value).reduce((object, key) => {
const [result, nestedTransferables = []] = encode(
(value as any)[key],
seen,
);
transferables.push(...nestedTransferables);
return {...object, [key]: result};
}, {});
const fullResult: EncodeResult = [result, transferables];
seen.set(value, fullResult);
return fullResult;
}
}
if (typeof value === 'function') {
if (functionsToId.has(value as AnyFunction)) {
const id = functionsToId.get(value as AnyFunction)!;
const result: EncodeResult = [{[FUNCTION]: id}];
seen.set(value, result);
return result;
}
const id = api.uuid();
functionsToId.set(value as AnyFunction, id);
idsToFunction.set(id, value as AnyFunction);
const result: EncodeResult = [{[FUNCTION]: id}];
seen.set(value, result);
return result;
}
const result: EncodeResult = [value];
seen.set(value, result);
return result;
}
function decode(value: unknown, retainedBy?: Iterable<Retainer>): any {
if (typeof value === 'object') {
if (value == null) {
return value as any;
}
if (Array.isArray(value)) {
return value.map((value) => decode(value, retainedBy));
}
if (FUNCTION in value) {
const id = (value as {[FUNCTION]: string})[FUNCTION];
if (idsToProxy.has(id)) {
return idsToProxy.get(id)! as any;
}
let retainCount = 0;
let released = false;
const release = () => {
retainCount -= 1;
if (retainCount === 0) {
released = true;
idsToProxy.delete(id);
api.release(id);
}
};
const retain = () => {
retainCount += 1;
};
const retainers = new Set(retainedBy);
const proxy = (...args: any[]) => {
if (released) {
throw new Error(
'You attempted to call a function that was already released.',
);
}
if (!idsToProxy.has(id)) {
throw new Error(
'You attempted to call a function that was already revoked.',
);
}
return api.call(id, args);
};
Object.defineProperties(proxy, {
[RELEASE_METHOD]: {value: release, writable: false},
[RETAIN_METHOD]: {value: retain, writable: false},
[RETAINED_BY]: {value: retainers, writable: false},
});
for (const retainer of retainers) {
retainer.add(proxy as any);
}
idsToProxy.set(id, proxy);
return proxy as any;
}
if (isBasicObject(value)) {
return Object.keys(value).reduce(
(object, key) => ({
...object,
[key]: decode((value as any)[key], retainedBy),
}),
{},
) as any;
}
}
return value as any;
}
}
export {createBasicEncoder} from './basic';
import {createBasicEncoder} from './encoding';
import type {
MessageEndpoint,
RemoteCallable,
EncodingStrategy,
EncodingStrategyApi,
} from './types';
import {StackFrame} from './memory';
import type {Retainer} from './memory';
export const CALL = 0;
export const RESULT = 1;
export const TERMINATE = 2;
export const RELEASE = 3;
export const FUNCTION_APPLY = 5;
export const FUNCTION_RESULT = 6;
type AnyFunction = (...args: any[]) => any;
interface MessageMap {
[CALL]: [string, string | number, any];
[RESULT]: [string, Error?, any?];
[TERMINATE]: void;
[RELEASE]: [string];
[FUNCTION_APPLY]: [string, string, any];
[FUNCTION_RESULT]: [string, Error?, any?];
}
type MessageData = {
[K in keyof MessageMap]: [K, MessageMap[K]];
}[keyof MessageMap];
export interface CreateEndpointOptions<T = unknown> {
uuid?(): string;
createEncoder?(api: EncodingStrategyApi): EncodingStrategy;
callable?: (keyof T)[];
}
export interface Endpoint<T> {
readonly call: RemoteCallable<T>;
replace(messenger: MessageEndpoint): void;
expose(api: Record<string, AnyFunction | undefined>): void;
callable(...methods: string[]): void;
terminate(): void;
}
export class MissingResolverError extends Error {
readonly callId: string;
readonly error?: Error;
readonly result?: unknown;
readonly groupingHash: string = 'RemoteUI::MissingResolverError';
constructor(message: {callId: string; error?: Error; result?: unknown}) {
const {callId, error, result} = message;
const errorMessage = error ? ` Error: ${String(error)}` : '';
const resultMessage =
result == null ? '' : ` Result: ${JSON.stringify(result)}`;
super(
`No resolver found for call ID: ${callId}${errorMessage}${resultMessage}`,
);
this.name = 'MissingResolverError';
this.callId = callId;
this.error = error;
this.result = result;
}
}
/**
* An endpoint wraps around a messenger, acting as the intermediary for all
* messages both send from, and received by, that messenger. The endpoint sends
* all messages as arrays, where the first element is the message type, and the
* second is the arguments for that message (as an array). For messages that send
* meaningful content across the wire (e.g., arguments to function calls, return
* results), the endpoint first encodes these values.
*
* Encoding is done using a CBOR-like encoding scheme. The value is encoded into
* an array buffer, and is paired with an additional array buffer that contains all
* the strings used in that message (in the encoded value, strings are encoded as
* their index in the "strings" encoding to reduce the cost of heavily-duplicated
* strings, which is more likely in payloads containing UI). This encoding also takes
* care of encoding functions: it uses a "tagged" item in CBOR to represent a
* function as a string ID, which the opposite endpoint will be capable of turning
* into a consistent, memory-manageable function proxy.
*
* The main CBOR encoding is entirely take from the [cbor.js package](https://github.com/paroga/cbor-js).
* The special behavior for encoding strings and functions was then added in to the
* encoder and decoder. For additional details on CBOR:
*
* @see https://tools.ietf.org/html/rfc7049
*/
export function createEndpoint<T>(
initialMessenger: MessageEndpoint,
{
uuid = defaultUuid,
createEncoder = createBasicEncoder,
callable,
}: CreateEndpointOptions<T> = {},
): Endpoint<T> {
let terminated = false;
let messenger = initialMessenger;
const activeApi = new Map<string | number, AnyFunction>();
const callIdsToResolver = new Map<
string,
(
...args: MessageMap[typeof FUNCTION_RESULT] | MessageMap[typeof RESULT]
) => void
>();
const call = createCallable<T>(handlerForCall, callable);
const encoder = createEncoder({
uuid,
release(id) {
send(RELEASE, [id]);
},
call(id, args, retainedBy) {
const callId = uuid();
const done = waitForResult(callId, retainedBy);
const [encoded, transferables] = encoder.encode(args);
send(FUNCTION_APPLY, [callId, id, encoded], transferables);
return done;
},
});
messenger.addEventListener('message', listener);
return {
call,
replace(newMessenger) {
const oldMessenger = messenger;
messenger = newMessenger;
oldMessenger.removeEventListener('message', listener);
newMessenger.addEventListener('message', listener);
},
expose(api) {
for (const key of Object.keys(api)) {
const value = api[key];
if (typeof value === 'function') {
activeApi.set(key, value);
} else {
activeApi.delete(key);
}
}
},
callable(...newCallable) {
// If no callable methods are supplied initially, we use a Proxy instead,
// so all methods end up being treated as callable by default.
if (callable == null) return;
for (const method of newCallable) {
Object.defineProperty(call, method, {
value: handlerForCall(method),
writable: false,
configurable: true,
enumerable: true,
});
}
},
terminate() {
send(TERMINATE, undefined);
terminate();
if (messenger.terminate) {
messenger.terminate();
}
},
};
function send<Type extends keyof MessageMap>(
type: Type,
args: MessageMap[Type],
transferables?: Transferable[],
) {
if (terminated) {
return;
}
messenger.postMessage(args ? [type, args] : [type], transferables);
}
async function listener(event: MessageEvent) {
if (terminated) {
return;
}
const {data} = event;
if (!isMessageData(data)) {
return;
}
switch (data[0]) {
case TERMINATE: {
terminate();
break;
}
case CALL: {
const stackFrame = new StackFrame();
const [id, property, args] = data[1];
const func = activeApi.get(property);
try {
if (func == null) {
throw new Error(
`No '${property}' method is exposed on this endpoint`,
);
}
const [encoded, transferables] = encoder.encode(
await func(...(encoder.decode(args, [stackFrame]) as any[])),
);
send(RESULT, [id, undefined, encoded], transferables);
} catch (error) {
const {name, message, stack} = error as Error;
send(RESULT, [id, {name, message, stack}]);
throw error;
} finally {
stackFrame.release();
}
break;
}
case RESULT: {
const [callId, error, result] = data[1];
const resolver = callIdsToResolver.get(callId);
if (resolver == null) {
throw new MissingResolverError({
callId,
error,
result,
});
}
resolver(...data[1]);
callIdsToResolver.delete(callId);
break;
}
case RELEASE: {
const [id] = data[1];
encoder.release(id);
break;
}
case FUNCTION_RESULT: {
const [callId, error, result] = data[1];
const resolver = callIdsToResolver.get(callId);
if (resolver == null) {
throw new MissingResolverError({
callId,
error,
result,
});
}
resolver(...data[1]);
callIdsToResolver.delete(callId);
break;
}
case FUNCTION_APPLY: {
const [callId, funcId, args] = data[1];
try {
const result = await encoder.call(funcId, args);
const [encoded, transferables] = encoder.encode(result);
send(FUNCTION_RESULT, [callId, undefined, encoded], transferables);
} catch (error) {
const {name, message, stack} = error as Error;
send(FUNCTION_RESULT, [callId, {name, message, stack}]);
throw error;
}
break;
}
}
}
function handlerForCall(property: string | number | symbol) {
return (...args: any[]) => {
if (terminated) {
return Promise.reject(
new Error(
'You attempted to call a function on a terminated web worker.',
),
);
}
if (typeof property !== 'string' && typeof property !== 'number') {
return Promise.reject(
new Error(
`Can’t call a symbol method on a remote endpoint: ${property.toString()}`,
),
);
}
const id = uuid();
const done = waitForResult(id);
const [encoded, transferables] = encoder.encode(args);
send(CALL, [id, property, encoded], transferables);
return done;
};
}
function waitForResult(id: string, retainedBy?: Iterable<Retainer>) {
return new Promise<any>((resolve, reject) => {
callIdsToResolver.set(id, (_, errorResult, value) => {
if (errorResult == null) {
resolve(value && encoder.decode(value, retainedBy));
} else {
const error = new Error();
Object.assign(error, errorResult);
reject(error);
}
});
});
}
function terminate() {
terminated = true;
activeApi.clear();
callIdsToResolver.clear();
encoder.terminate?.();
messenger.removeEventListener('message', listener);
}
}
function defaultUuid() {
return `${uuidSegment()}-${uuidSegment()}-${uuidSegment()}-${uuidSegment()}`;
}
function uuidSegment() {
return Math.floor(Math.random() * Number.MAX_SAFE_INTEGER).toString(16);
}
function createCallable<T>(
handlerForCall: (
property: string | number | symbol,
) => AnyFunction | undefined,
callable?: (keyof T)[],
): RemoteCallable<T> {
let call: any;
if (callable == null) {
if (typeof Proxy !== 'function') {
throw new Error(
`You must pass an array of callable methods in environments without Proxies.`,
);
}
const cache = new Map<string | number | symbol, AnyFunction | undefined>();
call = new Proxy(
{},
{
get(_target, property) {
if (cache.has(property)) {
return cache.get(property);
}
const handler = handlerForCall(property);
cache.set(property, handler);
return handler;
},
},
);
} else {
call = {};
for (const method of callable) {
Object.defineProperty(call, method, {
value: handlerForCall(method),
writable: false,
configurable: true,
enumerable: true,
});
}
}
return call;
}
function isMessageData(value: unknown): value is MessageData {
return (
Array.isArray(value) &&
typeof value[0] === 'number' &&
(value[1] == null || Array.isArray(value[1]))
);
}
export {createEndpoint} from './endpoint';
export type {Endpoint, CreateEndpointOptions} from './endpoint';
export {createBasicEncoder} from './encoding';
export {
fromMessagePort,
fromWebWorker,
fromIframe,
fromInsideIframe,
} from './adaptors';
export {
retain,
release,
StackFrame,
isBasicObject,
isMemoryManageable,
RELEASE_METHOD,
RETAIN_METHOD,
RETAINED_BY,
} from './memory';
export type {Retainer, MemoryManageable} from './memory';
export type {
EncodingStrategy,
EncodingStrategyApi,
RemoteCallable,
SafeRpcArgument,
MessageEndpoint,
MaybePromise,
} from './types';
import {RETAINED_BY, RETAIN_METHOD, RELEASE_METHOD} from './types';
import type {Retainer, MemoryManageable} from './types';
export {RETAINED_BY, RETAIN_METHOD, RELEASE_METHOD};
export type {Retainer, MemoryManageable};
export class StackFrame {
private readonly memoryManaged = new Set<MemoryManageable>();
add(memoryManageable: MemoryManageable) {
this.memoryManaged.add(memoryManageable);
memoryManageable[RETAINED_BY].add(this);
memoryManageable[RETAIN_METHOD]();
}
release() {
for (const memoryManaged of this.memoryManaged) {
memoryManaged[RETAINED_BY].delete(this);
memoryManaged[RELEASE_METHOD]();
}
this.memoryManaged.clear();
}
}
export function isMemoryManageable(value: unknown): value is MemoryManageable {
return Boolean(
value && (value as any)[RETAIN_METHOD] && (value as any)[RELEASE_METHOD],
);
}
export function retain(value: any, {deep = true} = {}): boolean {
return retainInternal(value, deep, new Map());
}
function retainInternal(
value: unknown,
deep: boolean,
seen: Map<any, boolean>,
): boolean {
const seenValue = seen.get(value);
if (seenValue != null) return seenValue;
const canRetain = isMemoryManageable(value);
if (canRetain) {
value[RETAIN_METHOD]();
}
seen.set(value, canRetain);
if (deep) {
if (Array.isArray(value)) {
const nestedCanRetain = value.reduce(
(canRetain, item) => retainInternal(item, deep, seen) || canRetain,
canRetain,
);
seen.set(value, nestedCanRetain);
return nestedCanRetain;
}
if (isBasicObject(value)) {
const nestedCanRetain = Object.keys(value).reduce<boolean>(
(canRetain, key) =>
retainInternal((value as any)[key], deep, seen) || canRetain,
canRetain,
);
seen.set(value, nestedCanRetain);
return nestedCanRetain;
}
}
seen.set(value, canRetain);
return canRetain;
}
export function release(value: any, {deep = true} = {}): boolean {
return releaseInternal(value, deep, new Map());
}
export function releaseInternal(
value: any,
deep: boolean,
seen: Map<any, boolean>,
): boolean {
const seenValue = seen.get(value);
if (seenValue != null) return seenValue;
const canRelease = isMemoryManageable(value);
if (canRelease) {
value[RELEASE_METHOD]();
}
seen.set(value, canRelease);
if (deep) {
if (Array.isArray(value)) {
const nestedCanRelease = value.reduce(
(canRelease, item) => releaseInternal(item, deep, seen) || canRelease,
canRelease,
);
seen.set(value, nestedCanRelease);
return nestedCanRelease;
}
if (isBasicObject(value)) {
const nestedCanRelease = Object.keys(value).reduce<boolean>(
(canRelease, key) =>
releaseInternal((value as any)[key], deep, seen) || canRelease,
canRelease,
);
seen.set(value, nestedCanRelease);
return nestedCanRelease;
}
}
return canRelease;
}
export function isBasicObject(value: unknown): value is object {
if (value == null || typeof value !== 'object') return false;
const prototype = Object.getPrototypeOf(value);
return prototype == null || prototype === Object.prototype;
}
class MessagePortPolyfill implements MessagePort {
onmessageerror: EventListener | null = null;
otherPort!: MessagePortPolyfill;
private listeners = new Set<EventListener>();
// MessagePort does not send messages unless it is started via start() or attaching .onmessage
// https://developer.mozilla.org/en-US/docs/Web/API/MessagePort/start
private started = false;
private _onmessage: EventListener | null = null;
// If the port is not yet started, messages will be queued for sending.
private eventQueue: Event[] = [];
get onmessage() {
return this._onmessage;
}
set onmessage(listener: EventListener | null) {
// setting onmessage will start the port, even if the listener is null.
this._onmessage = listener;
this.start();
}
dispatchEvent(event: Event) {
if (!this.started) {
this.eventQueue.push(event);
return true;
}
if (this._onmessage) {
this._onmessage(event);
}
for (const listener of this.listeners) {
listener(event);
}
return true;
}
postMessage(message: any) {
if (!this.otherPort) {
return;
}
this.otherPort.dispatchEvent({data: message} as any);
}
addEventListener(type: string, listener: EventListener) {
if (type !== 'message') {
return;
}
this.listeners.add(listener);
}
removeEventListener(type: string, listener: EventListener) {
if (type !== 'message') {
return;
}
this.listeners.delete(listener);
}
start() {
this.started = true;
while (this.eventQueue.length > 0) {
const event = this.eventQueue.shift();
if (event) {
this.dispatchEvent(event);
}
}
}
close() {
this.started = false;
}
}
class MessageChannelPolyfill implements MessageChannel {
readonly port1: MessagePortPolyfill;
readonly port2: MessagePortPolyfill;
constructor() {
this.port1 = new MessagePortPolyfill();
this.port2 = new MessagePortPolyfill();
this.port1.otherPort = this.port2;
this.port2.otherPort = this.port1;
}
}
export {MessageChannelPolyfill as MessageChannel};
export interface MessageEndpoint {
postMessage(message: any, transferables?: Transferable[]): void;
addEventListener(
event: 'message',
listener: (event: MessageEvent) => void,
): void;
removeEventListener(
event: 'message',
listener: (event: MessageEvent) => void,
): void;
terminate?(): void;
}
export type RemoteCallable<T> = {[K in keyof T]: RemoteCallableField<T[K]>};
type RemoteCallableField<T> = T extends (
...args: infer Args
) => infer TypeReturned
? (...args: Args) => AlwaysAsync<TypeReturned>
: never;
export type MaybePromise<T> = T extends Promise<any> ? T : T | Promise<T>;
type AlwaysAsync<T> = T extends Promise<any>
? T
: T extends infer U | Promise<infer U>
? Promise<U>
: T extends (...args: infer Args) => infer TypeReturned
? (...args: Args) => AlwaysAsync<TypeReturned>
: T extends (infer ArrayElement)[]
? AlwaysAsync<ArrayElement>[]
: T extends ReadonlyArray<infer ArrayElement>
? ReadonlyArray<AlwaysAsync<ArrayElement>>
: T extends object
? {[K in keyof T]: AlwaysAsync<T[K]>}
: T;
export type SafeRpcArgument<T> = T extends (
...args: infer Args
) => infer TypeReturned
? TypeReturned extends Promise<any>
? (...args: Args) => TypeReturned
: (...args: Args) => TypeReturned | Promise<TypeReturned>
: T extends (infer ArrayElement)[]
? SafeRpcArgument<ArrayElement>[]
: T extends ReadonlyArray<infer ArrayElement>
? ReadonlyArray<SafeRpcArgument<ArrayElement>>
: T extends object
? {[K in keyof T]: SafeRpcArgument<T[K]>}
: T;
export const RETAIN_METHOD = Symbol.for('RemoteUi::Retain');
export const RELEASE_METHOD = Symbol.for('RemoteUi::Release');
export const RETAINED_BY = Symbol.for('RemoteUi::RetainedBy');
export interface Retainer {
add(manageable: MemoryManageable): void;
}
export interface MemoryManageable {
readonly [RETAINED_BY]: Set<Retainer>;
[RETAIN_METHOD](): void;
[RELEASE_METHOD](): void;
}
export interface EncodingStrategy {
encode(value: unknown): [any, Transferable[]?];
decode(value: unknown, retainedBy?: Iterable<Retainer>): unknown;
call(id: string, args: any[]): Promise<any>;
release(id: string): void;
terminate?(): void;
}
export interface EncodingStrategyApi {
uuid(): string;
release(id: string): void;
call(id: string, args: any[], retainedBy?: Iterable<Retainer>): Promise<any>;
}
import {createPackage, Runtime} from '@shopify/loom';
import {defaultProjectPlugin} from '../../config/loom';
export default createPackage((pkg) => {
pkg.runtimes(Runtime.Node, Runtime.Browser);
pkg.entry({root: './src/index.ts'});
pkg.use(defaultProjectPlugin());
});
{
"name": "@remote-ui/types",
"version": "1.1.3",
"publishConfig": {
"access": "public",
"@remote-ui:registry": "https://registry.npmjs.org"
},
"license": "MIT",
"main": "index.js",
"module": "index.mjs",
"esnext": "index.esnext",
"types": "./build/ts/index.d.ts",
"exports": {
".": {
"types": "./build/ts/index.d.ts",
"esnext": "./index.esnext",
"import": "./index.mjs",
"require": "./index.js"
}
},
"sideEffects": false
}
export type RemoteComponentType<
Type extends string,
Props = Record<string, never>,
AllowedChildren extends RemoteComponentType<string, any> | boolean = true,
> =
// If we don't include the object part, this type gets "erased" to just
// be the string type, which means the props/ children can’t be extracted
// from the object later.
Type & {
readonly type?: Type;
readonly props?: Props;
readonly children?: AllowedChildren;
};
export type IdentifierForRemoteComponent<T> = T extends RemoteComponentType<
infer Type,
any,
any
>
? Type
: never;
export type PropsForRemoteComponent<T> = T extends RemoteComponentType<
string,
infer Props,
any
>
? Props
: never;
export type AllowedChildrenForRemoteComponent<T> =
T extends RemoteComponentType<string, any, infer Children>
? Children extends true
? RemoteComponentType<string, any, any>
: Children extends false
? never
: Children
: never;
{
"name": "@shopify/ui-extensions-react",
"version": "2025.7.4",
"description": "React bindings for @shopify/ui-extensions",
"publishConfig": {
"access": "public",
"@shopify:registry": "https://registry.npmjs.org"
},
"sideEffects": false,
"license": "MIT",
"main": "index.js",
"module": "index.mjs",
"esnext": "index.esnext",
"types": "./build/ts/index.d.ts",
"typesVersions": {
"*": {
"admin": [
"./build/ts/surfaces/admin.d.ts"
],
"checkout": [
"./build/ts/surfaces/checkout.d.ts"
],
"customer-account": [
"./build/ts/surfaces/customer-account.d.ts"
],
"point-of-sale": [
"./build/ts/surfaces/point-of-sale.d.ts"
]
}
},
"exports": {
".": {
"types": "./build/ts/index.d.ts",
"esnext": "./index.esnext",
"import": "./index.mjs",
"require": "./index.js"
},
"./admin": {
"types": "./build/ts/surfaces/admin.d.ts",
"esnext": "./build/esnext/surfaces/admin.esnext",
"import": "./build/esm/surfaces/admin.mjs",
"require": "./build/cjs/surfaces/admin.js"
},
"./checkout": {
"types": "./build/ts/surfaces/checkout.d.ts",
"esnext": "./build/esnext/surfaces/checkout.esnext",
"import": "./build/esm/surfaces/checkout.mjs",
"require": "./build/cjs/surfaces/checkout.js"
},
"./customer-account": {
"types": "./build/ts/surfaces/customer-account.d.ts",
"esnext": "./build/esnext/surfaces/customer-account.esnext",
"import": "./build/esm/surfaces/customer-account.mjs",
"require": "./build/cjs/surfaces/customer-account.js"
},
"./point-of-sale": {
"types": "./build/ts/surfaces/point-of-sale.d.ts",
"esnext": "./build/esnext/surfaces/point-of-sale.esnext",
"import": "./build/esm/surfaces/point-of-sale.mjs",
"require": "./build/cjs/surfaces/point-of-sale.js"
}
},
"dependencies": {
"@remote-ui/async-subscription": "^2.1.16",
"@remote-ui/react": "^5.0.6",
"@types/react": ">=18.2.67"
},
"peerDependencies": {
"@shopify/ui-extensions": "2025.7.4",
"react": ">=18.0.0"
},
"peerDependenciesMeta": {
"@shopify/ui-extensions": {
"optional": false
},
"react": {
"optional": false
}
},
"devDependencies": {
"@faker-js/faker": "^8.4.1",
"@quilted/react-testing": "^0.5.31",
"@shopify/ui-extensions": "2025.7.4",
"react": "^18.0.0",
"react-reconciler": "0.29.0",
"react-test-renderer": "^18.2.0"
},
"repository": {
"type": "git",
"url": "https://github.com/Shopify/ui-extensions.git",
"directory": "packages/ui-extensions"
},
"files": [
"build",
"src",
"index.*",
"checkout.*",
"README.md"
],
"eslintConfig": {
"rules": {
"@shopify/strict-component-boundaries": "off",
"react/react-in-jsx-scope": "off"
}
}
}
{
"name": "@shopify/ui-extensions",
"version": "2025.10.15",
"scripts": {
"docs:admin": "node ./docs/surfaces/admin/build-docs.mjs",
"docs:checkout": "bash ./docs/surfaces/checkout/build-docs.sh",
"docs:point-of-sale": "node ./docs/surfaces/point-of-sale/build-docs.mjs",
"docs:customer-account": "node ./docs/surfaces/customer-account/build-docs.mjs"
},
"main": "index.js",
"module": "index.mjs",
"esnext": "index.esnext",
"types": "./build/ts/index.d.ts",
"typesVersions": {
"*": {
"admin": [
"./build/ts/surfaces/admin.d.ts"
],
"admin.*": [
"./build/ts/surfaces/admin/targets/admin.*"
],
"admin/components/*": [
"./build/ts/surfaces/admin/components/*.d.ts"
],
"checkout": [
"./build/ts/surfaces/checkout.d.ts"
],
"checkout/preact": [
"./build/ts/surfaces/checkout/preact/index.d.ts"
],
"purchase.*": [
"./build/ts/surfaces/checkout/targets/purchase.*"
],
"customer-account": [
"./build/ts/surfaces/customer-account.d.ts"
],
"customer-account.*": [
"./build/ts/surfaces/customer-account/targets/customer-account.*.d.ts"
],
"customer-account/preact": [
"./build/ts/surfaces/customer-account/preact/index.d.ts"
],
"point-of-sale": [
"./build/ts/surfaces/point-of-sale.d.ts"
],
"pos.*": [
"./build/ts/surfaces/point-of-sale/targets/pos.*.d.ts"
]
}
},
"exports": {
".": {
"types": "./build/ts/index.d.ts",
"esnext": "./index.esnext",
"import": "./index.mjs",
"require": "./index.js"
},
"./admin": {
"types": "./build/ts/surfaces/admin.d.ts",
"esnext": "./build/esnext/surfaces/admin.esnext",
"import": "./build/esm/surfaces/admin.mjs",
"require": "./build/cjs/surfaces/admin.js"
},
"./admin.*": {
"default": "./build/ts/surfaces/admin/targets/admin.*.d.ts"
},
"./admin/components/*": {
"default": "./build/ts/surfaces/admin/components/*.d.ts"
},
"./checkout": {
"types": "./build/ts/surfaces/checkout.d.ts",
"esnext": "./build/esnext/surfaces/checkout.esnext",
"import": "./build/esm/surfaces/checkout.mjs",
"require": "./build/cjs/surfaces/checkout.js"
},
"./checkout/preact": {
"types": "./build/ts/surfaces/checkout/preact/index.d.ts",
"esnext": "./build/esnext/surfaces/checkout/preact/index.esnext",
"import": "./build/esm/surfaces/checkout/preact/index.mjs",
"require": "./build/cjs/surfaces/checkout/preact/index.js"
},
"./purchase.*": {
"default": "./build/ts/surfaces/checkout/targets/purchase.*.d.ts"
},
"./customer-account": {
"types": "./build/ts/surfaces/customer-account.d.ts",
"esnext": "./build/esnext/surfaces/customer-account.esnext",
"import": "./build/esm/surfaces/customer-account.mjs",
"require": "./build/cjs/surfaces/customer-account.js"
},
"./customer-account.*": {
"default": "./build/ts/surfaces/customer-account/targets/customer-account.*.d.ts"
},
"./customer-account/preact": {
"types": "./build/ts/surfaces/customer-account/preact/index.d.ts",
"esnext": "./build/esnext/surfaces/customer-account/preact/index.esnext",
"import": "./build/esm/surfaces/customer-account/preact/index.mjs",
"require": "./build/cjs/surfaces/customer-account/preact/index.js"
},
"./point-of-sale": {
"types": "./build/ts/surfaces/point-of-sale.d.ts",
"esnext": "./build/esnext/surfaces/point-of-sale.esnext",
"import": "./build/esm/surfaces/point-of-sale.mjs",
"require": "./build/cjs/surfaces/point-of-sale.js"
},
"./pos.*": {
"default": "./build/ts/surfaces/point-of-sale/targets/pos.*.d.ts"
},
"./point-of-sale/preact": {
"types": "./build/ts/surfaces/point-of-sale/preact.d.ts",
"esnext": "./build/esnext/surfaces/point-of-sale/preact.esnext",
"import": "./build/esm/surfaces/point-of-sale/preact.mjs",
"require": "./build/cjs/surfaces/point-of-sale/preact.js"
},
"./preact": {
"types": "./build/ts/preact.d.ts",
"esnext": "./build/esnext/preact.esnext",
"import": "./build/esm/preact.mjs",
"require": "./build/cjs/preact.js"
}
},
"license": "MIT",
"sideEffects": [
"./build/esnext/preact.esnext",
"./build/esm/preact.mjs",
"./build/cjs/preact.js"
],
"devDependencies": {
"@remote-ui/async-subscription": "^2.1.16",
"@shopify/generate-docs": "1.2.2",
"@quilted/react-testing": "^0.6.11",
"typescript": "^4.9.0",
"@faker-js/faker": "^8.4.1",
"preact": "^10.10.x",
"@preact/signals": "^2.3.x"
},
"peerDependencies": {
"preact": "*",
"@preact/signals": "*"
},
"peerDependenciesMeta": {
"preact": {
"optional": true
},
"@preact/signals": {
"optional": true
}
},
"publishConfig": {
"access": "public",
"@shopify:registry": "https://registry.npmjs.org/"
},
"repository": {
"type": "git",
"url": "https://github.com/Shopify/ui-extensions.git",
"directory": "packages/ui-extensions"
},
"files": [
"build",
"src",
"index.*",
"README.md"
],
"eslintConfig": {
"rules": {
"@shopify/strict-component-boundaries": "off"
}
},
"dependencies": {
"ts-morph": "^25.0.1"
}
}
export const DiscreteEventPriority = 0b0000000000000000000000000000001;
export const ContinuousEventPriority = 0b0000000000000000000000000000100;
export const DefaultEventPriority = 0b0000000000000000000000000010000;
export const IdleEventPriority = 0b0100000000000000000000000000000;
export const LegacyRoot = 0;
export const ConcurrentRoot = 1;
/**
* Customize the displayed name of a useState, useReducer or useRef hook
* in the devtools panel.
*
* @param value Wrapped native hook.
* @param name Custom name
*/
export function addHookName<T>(value: T, name: string): T;
{
"name": "shopify-pos-ui",
"private": true,
"type": "module",
"dependencies": {
"typescript": "5.9.3"
}
}
Related skills
How it compares
Pick shopify-pos-ui over generic frontend-design skills when building in-store Shopify POS extensions that must match official POS UI component APIs.
FAQ
Can I manually create POS extension files?
No. Always use Shopify CLI to scaffold extensions and never hand-create app structure or configuration files.
What validation flag is required for POS UI code?
Pass --target with the POS extension target such as pos.customer-details.block.render or validation fails.
What is the required response workflow?
Run search_docs.mjs, write Preact code, then validate.mjs with all required flags before returning output to the user.
Is Shopify Pos Ui safe to install?
skills.sh reports 1 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.