Install from GitHub

npm install git+https://github.com/Westbrook/reference-target-polyfill.git#main

The package is named reference-target-fallback; it is not published to the npm registry. Replace main with a reviewed tag or commit SHA for a repeatable install, and commit the application lockfile. The package-import examples below assume a bundler.

Using browser modules without a bundler

Use the optimized browser entry URLs in place of package imports in the recipes below:

import { hasNativeReferenceTarget } from
  "https://westbrook.github.io/reference-target-polyfill/browser/detect/surface.js";

// On the native-surface branch in bootstrap.js:
if (hasNativeReferenceTarget()) {
  const { probeReferenceTarget } = await import(
    "https://westbrook.github.io/reference-target-polyfill/browser/detect.js"
  );
  // Apply the same application-specific probe policy shown below.
}

// In reference-target.setup.js:
import { installReferenceTarget } from
  "https://westbrook.github.io/reference-target-polyfill/browser/core.js";
import { labels } from
  "https://westbrook.github.io/reference-target-polyfill/browser/adapters/labels.js";

These files are minified ES modules with shared chunks. The browser manifest lists the entries, imports, and raw/gzip/Brotli byte counts. At a reviewed checkout, run npm ci && npm run build:browser, then copy the complete generated dist/browser/ directory to your own origin; a single entry can depend on a hashed shared chunk. The hosted URLs track the deployed repository site and are not a versioned CDN.

Raw detection, core, and adapter modules remain available for debugging and source-level evaluation. They expose a larger unminified, multi-request graph and are not the production no-bundler recommendation.

Fetch in parallel; evaluate in order

Load bootstrap.js as a module and keep installation in its own dynamic boundary. Preload the always-needed app so the setup barrier does not also become a cold-network waterfall:

<link rel="modulepreload" href="./app.js">
<script type="module" src="./bootstrap.js"></script>
bootstrap.js
import { hasNativeReferenceTarget } from
  "reference-target-fallback/detect/surface";

if (!hasNativeReferenceTarget()) {
  await import("./reference-target.setup.js");
} else {
  const { probeReferenceTarget } = await import(
    "reference-target-fallback/detect"
  );
  const support = probeReferenceTarget();
  if (!support.nullable || !support.labels) {
    // Choose an application-specific structural fallback or unsupported state.
    document.documentElement.dataset.referenceTargetSupport = "partial";
  }
}

await import("./app.js");
reference-target.setup.js
import { installReferenceTarget } from "reference-target-fallback/core";
import { labels } from "reference-target-fallback/adapters/labels";
import { popoverTargets } from "reference-target-fallback/adapters/popover-targets";
import { publishReferenceTarget } from "./reference-target.state.js";

export const referenceTarget = installReferenceTarget({
  adapters: [labels({ activation: "focus" }), popoverTargets()],
});

publishReferenceTarget(referenceTarget);

Every browser downloads only the tiny surface check. The full probe is requested on the native-surface route; the core and selected adapters are requested only when that surface is absent. The basic probe exercises nullable assignment and label forwarding, but does not certify popovers, commands, forms, text naming, accessible output, or assistive technology. Treat a passing native path as capability-unverified until it passes your application’s browser matrix. The installer will not layer fallback activation over a partial native surface.

app.js defines components and initializes normally when imported. The installer captures later attachShadow() calls, including closed roots:

const root = this.attachShadow({
  mode: "closed",
  referenceTarget: "control",
});
root.innerHTML = '<input id="control" type="email">';
Keep the handle without making setup eager

Both setup and application code may import this tiny state module. It never imports the fallback:

// reference-target.state.js
let current = null;
export function publishReferenceTarget(handle) { current = handle; }
export function getReferenceTarget() { return current; }
export function disposeReferenceTarget() {
  const previous = current;
  current = null;
  previous?.dispose();
}

// Later, in application code:
getReferenceTarget()?.refresh();

Call disposeReferenceTarget() before hot replacement or a same-realm reinstall. Do not statically import reference-target.setup.js from the app; that would pull the selected adapters into its eager graph.

Do not call hydrate() for roots created after installation. Reserve it for already-parsed open/declarative roots or changed host metadata. If your bundler uses sideEffects: false, preserve setup as side-effectful. Install once per realm. Under CSP, allow these self-hosted module URLs in script-src; no eval is used.

Only the adapters you select

Import each factory from reference-target-fallback/adapters/<module> and add it to the same adapters array. There is no “all adapters” entry point.

Open a demo to try the behavior and inspect its measured JavaScript size.
Capability / moduleFactoryWhat it supplies
Labels labelslabels({ activation: "focus" })External label activation. Choose "focus-and-click" to also activate a control; naming: true opts into outward label naming.
Popover targeting popover-targetspopoverTargets()Show, hide, or toggle an inner popover through popovertarget.
Dialog commands dialog-commandsdialogCommands()show-modal, close, and request-close through commandfor.
Popover commands popover-commandspopoverCommands()show-popover, hide-popover, and toggle-popover commands.
Text names text-namestextNames({ getText })Plain-text approximations for inward aria-labelledby and aria-describedby, using a component’s public text.
Form actions form-targetsformTargets()Outside submit and reset controls act on an inner native form.
Providing public text for names and descriptions
import { textNames } from "reference-target-fallback/adapters/text-names";

const names = textNames({
  getText(host, kind) {
    return host.getAttribute(
      kind === "label" ? "data-label-text" : "data-description-text",
    );
  },
});
// Add names to the installation's adapters array.

Return a string to opt in, or null to leave a reference alone. The provider receives the public host, never its private target. It runs synchronously during reconciliation, so keep it pure, fast, and free of DOM writes. Call getReferenceTarget()?.refresh() once after batching text changes held only in your application model.

The resulting text proxies are visually hidden but live in the source’s own tree and remain visible to same-tree scripts, DOM observers, developer tools, and accessibility computation. Do not put secrets in provider text or treat proxy IDs as application API.

Keep the fields inside the form

For a forms-only setup, select formTargets(). Use the same conditional bootstrap from above.

reference-target.setup.js — forms only
import { installReferenceTarget } from "reference-target-fallback/core";
import { formTargets } from "reference-target-fallback/adapters/form-targets";

export const referenceTarget = installReferenceTarget({
  adapters: [formTargets()],
});
// This page already contains parser-created declarative markup.
referenceTarget.hydrate();
<x-profile id="profile" data-reference-target="form">
  <template shadowrootmode="open" shadowrootreferencetarget="form">
    <form id="form">
      <label>Email <input name="email" type="email" required></label>
    </form>
  </template>
</x-profile>
<button type="submit" form="profile" name="intent" value="save" disabled>
  Save
</button>
<button type="reset" form="profile" disabled>Reset</button>
app.js — collect the data before doing asynchronous work
const form = document.getElementById("profile")
  .shadowRoot.getElementById("form");

form.addEventListener("submit", (event) => {
  event.preventDefault();
  const data = new FormData(form, event.submitter);
  submitProfile(data); // Application-owned secure transport; do not log values.
});

for (const button of document.querySelectorAll('[form="profile"]')) {
  button.disabled = false;
}

Keep outside actions disabled until installation and the form’s own listeners are ready. Native validation, submit/reset events, and the form’s own fields remain in use. In fallback mode, event.submitter is a temporary inner submitter carrying the outside control’s authored name, value, and form overrides. Read FormData synchronously while it exists; the proxy and copied values are briefly visible to code inside the form component, so the component and invoker must share a trust boundary.

This forwards actions; it does not associate external data controls or change .form and .elements. Image submitters are unsupported. Try validation, submitter data, and reset

Leave a hint on the host

For declarative Shadow DOM, keep data-reference-target on the host. Call referenceTarget.hydrate(container) once after the markup is available. It traverses that container once, discovers open roots, reads the hint, and reconciles known state; roots created later through attachShadow() do not need hydration.

<label for="email">Email</label>
<x-email id="email" data-reference-target="control">
  <template shadowrootmode="open" shadowrootreferencetarget="control">
    <input id="control" type="email">
  </template>
</x-email>

The browser must already support declarative Shadow DOM. Hydrate again only after adding pre-existing roots or changing host metadata. Removing metadata clears a target owned by hydration but does not overwrite a later programmatic assignment. Existing closed roots need cooperation from code that already holds the root:

const registration = referenceTarget.register(rootKnownToTheComponent, {
  referenceTarget: "control",
});

// When the component no longer participates:
registration.dispose();

Know what the browser still supplies

AreaRequirement or policy
Core realmA browser Window with a document, native Shadow DOM, MutationObserver, WeakRef, and queueMicrotask. Imports are side-effect free and SSR-safe; installation is client-only.
Module loadingESM is required; optimized direct-browser modules target ES2022. The recipe uses dynamic import, top-level await, and optional modulepreload. Wrap it in an async function if your output target cannot emit top-level await.
FramesInstall once per same-origin realm that creates participating roots, passing that frame’s window as realm. Roots registered with a handle must come from its realm. Cross-origin frames are inaccessible.
Action adaptersPopover, dialog, and form adapters call the browser’s existing native methods. They do not polyfill top-layer behavior, dialog focus, validation, or form ownership.
ARIA namingOpt-in label naming additionally needs working outward ariaLabelledByElements. Text proxies approximate plain text only. Property readback is not an accessibility result.
Declarative rootsDeclarative Shadow DOM is optional and not polyfilled. Hydration can discover parsed open roots with retained host metadata; existing closed roots need cooperation.

There is no CommonJS or package-root entry point. A bundler may transpile syntax, but it cannot supply missing DOM primitives. Under CSP, allow the self-hosted module URLs in script-src; the package does not require eval.

Measure bytes and main-thread work separately

The demo cards report minified and separately gzipped functional JavaScript: bootstrap, application, selected fallback, and their shared chunks. They exclude HTML, separate CSS, source maps, HTTP headers, and the eagerly requested syntax highlighter. Use the browser Network and Resource Timing views for full-page transfer, request count, caching, and latency. The optimized browser/ distribution is also minified; raw src/ modules are the larger debug/evaluation path.

Executable package-composition budgets independently cap surface detection, full detection, core, every core-plus-one-adapter path, and all public runtime modules in minified/raw, gzip, and Brotli bytes. Those caps are not gallery or full-page totals. Automatic native-surface mode must request no core/setup/adapter code. Hydration traverses its supplied container once; refresh initiates no scan, but drains queued records and may discover only their added subtrees before reconciliation. Irrelevant mutations do not run adapters, relevant bursts coalesce, and action-only selections do not run naming scans.

No p50/p95 load, installation, render, or update result is claimed by the size cards. The fallback does not render component UI, so report framework rendering separately from fallback installation, reconciliation, and action work. A reproducible performance run must record commit, browser, operating system, CPU/device, power mode, cache state, sample count, and fixture. Cover cold/warm navigation; 1/100/1,000 hosts; nested roots; irrelevant and relevant mutation bursts; explicit refresh; text providers; action latency; and retained state after disposal. Report median and p95 separately.

Inspect the generated functional demo-size manifest or the optimized browser-module manifest. Package caps are executable in tests/package-sizes.test.js; release-current values stay in the generated manifests instead of hand-maintained prose. A budget increase needs an intentional baseline change and explanation.

A fallback with a defined scope

  • Accessibility: label naming and text proxies are approximations. Arbitrary cross-root ARIA relationships and full accessible-name computation are outside this package. Test with your browsers and assistive technology.
  • Native primitives: Shadow DOM, MutationObserver, WeakRef, and queueMicrotask are required. Action adapters use the browser’s existing popover, dialog, and form methods.
  • Encapsulation: the public handle and built-in callbacks expose no private target resolver. Native reflection, event paths, and full form ownership cannot be recreated here. Built-in adapter descriptors are opaque, privileged internals.
  • Detection: hasNativeReferenceTarget() checks the API surface. probeReferenceTarget() adds basic nullable-value and label tests; neither certifies every selected behavior. A passing native route is reported as native-unverified. The installer declines fallback activation over detected partial native support. force: true is for tests only.

Inspect referenceTarget.mode, .statuses, and .activeAdapters for the live state. Modes include fallback, native-unverified, unsupported, inactive, and disposed; individual adapter statuses distinguish the same routing states. The package includes TypeScript declarations for public factories and handles. TypeScript component code can opt into native-shaped ShadowRootInit.referenceTarget and ShadowRoot.referenceTarget declarations with the type-only statement import type {} from "reference-target-fallback/dom"; that subpath has no runtime module.

To release listeners, observers, adapter changes, and the owned attachShadow() wrapper, including before hot replacement:

referenceTarget.dispose();
Diagnostic codes

onDiagnostic({ code, detail }) is synchronous telemetry. Keep it fast and nonthrowing, avoid DOM writes, and ignore unknown future codes. Built-in codes are missing-primitive, labels-naming-unavailable, unresolved-target, unsupported-target, invalid-action-state, text-provider-value, text-provider-error, unsupported-image-submitter, disabled-submitter-proxy, root-discovery-error, activation-errors, and refresh-errors. Automatic root discovery reports its sanitized error and continues with later roots; explicit register() and hydrate() errors still throw. The last two codes are internal callback-safety diagnostics. Automatic reconciliation reports refresh errors without an uncaught observer-task failure, while synchronous install, hydrate(), or refresh() also throws an AggregateError. Detail fields may grow additively during 0.x; private nodes are reduced to public host metadata.

Retire the fallback only after each selected native capability and its assistive-technology outcome passes the application’s browser matrix. Remove provider hooks and declarative fallback metadata separately, dispose the live handle, and then remove setup/detection.

Full API and behavior notes · Research & design proposal