Skip to content

Commit 49d5b95

Browse files
committed
[Update] webcomponents, browser: callable cells, stricter hash path/flag semantics, and adopted stylesheets
This update introduces support for constructable/adopted stylesheets in modern browser environments, refines document style synchronization, and implements flexible proxy-based selectable/callable cells for browser.hash and browser.query. The hash parsing logic now features refined path and flag semantics: top-level bare values without slashes parse into a path property and a companion boolean flag (e.g. #new parses to { path: "new", new: true }), whereas values with slashes parse into the path property only (e.g. #login/new parses to { path: "login/new" }). Top-level parenthesized groups are parsed directly as arrays. Browser.hash and Browser.query cells are now wrapped in a Proxy that can be called directly with a key/subpath argument to retrieve a selected child view, while preserving complete backward compatibility with all properties and methods of the underlying Cell instance. On the WebComponent side, document styles are now synchronized and cloned into shadow roots using constructable stylesheets (via CSSStyleSheet.replaceSync) in supported environments, avoiding raw <style> element insertions where possible. Style node synchronization signature calculations have been hardened by introducing a 32-bit FNV-like text hashing mechanism (hashText) rather than relying purely on text lengths. There are no breaking changes; all modified APIs and refactored helper relocations remain completely backward-compatible. Changed: - browser: Updated Browser.prototype.hash and Browser.prototype.query properties to return a callable proxy wrapper, enabling direct key selection via function calls (e.g., state.hash("key")) while retaining all original Cell APIs. - browser: Updated parseHash(value) logic to support stricter path/flag semantics. A bare first segment sets a path property, setting a matching boolean flag unless it contains a slash /. Outermost parenthesized values parse directly as arrays. - webcomponents: Clones and syncs document styles into WebComponent shadow roots using adopted constructable stylesheets (CSSStyleSheet) in supported browsers, falling back to style elements where needed. - webcomponents: Enhanced getDocumentStylesSignature(doc) to compute a 32-bit hash (hashText) of style element text contents, ensuring high-fidelity change detection. - utils: Relocated the def helper function from src/js/select/utils/func.js to src/js/select/utils/values.js. (Exports via the central utils.js compatibility barrel are unchanged and fully backward-compatible). Added: - browser: Added parse(value, fallback = {}) method to browser.hash for array-capable parsing and record sanitization. - webcomponents: Added static options definition (webcomponent.options) to expose configuration defaults and exported webcomponent as default. - tests: Added comprehensive test suite in tests/browser-parse.test.js covering hash-parsing, slash behavior, parenthesized group arrays, and edge cases. - tests: Added adopted stylesheet and dynamic content mutation tests in tests/ui-webcomponent-children.test.js. Removed: - utils: Removed def helper from src/js/select/utils/func.js (re-added under utils/values.js).
1 parent 2b7370e commit 49d5b95

8 files changed

Lines changed: 278 additions & 37 deletions

File tree

docs/browser.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ Notes:
7272
- hash fragments may start with `#`
7373
- query parsing ignores any trailing `#fragment`
7474
- legacy `a=1&b=2` query syntax is not supported by default
75+
- hash parsing treats a bare first value as a `path` key; see [Browser Reference](ref-browser.md#hash) for details
7576

7677
## Error Handling
7778

docs/ref-browser.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,11 @@ Parsing rules:
9696
- special characters that force quoting include `&`, `,`, `(`, `)`, `=`, and `"`
9797
- any keyed entry inside `(...)` switches that group into object mode
9898
- bare items inside an object-mode group become boolean flags like `checked=T`
99+
- top-level parenthesized groups are parsed as arrays: `(a,b)``["a","b"]`
100+
- at the top level (outside parens), a bare first value (no `=`) becomes a `path` key:
101+
- if the path value contains `/`, it sets `path` only (no boolean flag)
102+
- without `/`, it also sets a boolean flag with the same name (`<value>: true`)
103+
- subsequent bare values after the first `,` become boolean flags
99104

100105
Formatting rules:
101106

@@ -116,6 +121,15 @@ hash.parse("#text=\"hello, world\",flag=T")
116121
hash.parse("(label=A,checked)")
117122
// => { label: "A", checked: true }
118123

124+
hash.parse("#new,old")
125+
// => { path: "new", new: true, old: true }
126+
127+
hash.parse("#login/new")
128+
// => { path: "login/new" }
129+
130+
hash.parse("#(new,old)")
131+
// => ["new", "old"]
132+
119133
hash.format({ z: 2, a: [1, 3], text: "hello, world" })
120134
// => "a=(1,3),text=\"hello, world\",z=2"
121135

src/js/select/browser.js

Lines changed: 82 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,13 @@ class HashFormat extends RecordFormat {
240240
super("browser.hash", serializer, warn);
241241
}
242242

243+
parse(value, fallback = {}) {
244+
const text = this.decodeText(value);
245+
const parsed = this.safeParse(text, fallback);
246+
if (Array.isArray(parsed)) return parsed;
247+
return this.sanitizeRecord(parsed);
248+
}
249+
243250
static DecodeComponent(value) {
244251
if (!value?.includes("%")) return value;
245252
try {
@@ -576,6 +583,48 @@ function formatRecord(value) {
576583
function parseHash(value) {
577584
const source = `${value || ""}`.replace(/^#/, "");
578585
if (!source) return {};
586+
if (source.startsWith("(")) {
587+
const parsed = HashFormat.ParseHash(source);
588+
return normalizeHashValue(parsed);
589+
}
590+
const [sepIdx, sep] = HashFormat.NextSeparator(source, 0);
591+
const firstSegment = sepIdx === null ? source : source.substring(0, sepIdx).trim();
592+
if (firstSegment && sep !== "=") {
593+
const pathValue = HashFormat.ParseAtom(firstSegment);
594+
const pathStr = `${pathValue}`;
595+
if (sep !== ",") {
596+
return pathStr.includes("/")
597+
? { path: pathValue }
598+
: { path: pathValue, [pathStr]: true };
599+
}
600+
const remaining = source.substring(sepIdx + 1).trim();
601+
if (!remaining) {
602+
return pathStr.includes("/")
603+
? { path: pathValue }
604+
: { path: pathValue, [pathStr]: true };
605+
}
606+
const rest = normalizeHashValue(HashFormat.ParseHash(remaining));
607+
if (Array.isArray(rest)) {
608+
const result = { path: pathValue };
609+
if (!pathStr.includes("/")) result[pathStr] = true;
610+
for (let i = 0; i < rest.length; i++) result[rest[i]] = true;
611+
return result;
612+
}
613+
if (typeof rest === "string") {
614+
const result = { path: pathValue };
615+
if (!pathStr.includes("/")) result[pathStr] = true;
616+
result[rest] = true;
617+
return result;
618+
}
619+
if (isObject(rest)) {
620+
rest.path = pathValue;
621+
if (!pathStr.includes("/")) rest[pathStr] = true;
622+
return rest;
623+
}
624+
return pathStr.includes("/")
625+
? { path: pathValue }
626+
: { path: pathValue, [pathStr]: true };
627+
}
579628
const parsed = HashFormat.ParseHash(source);
580629
return normalizeHashValue(parsed);
581630
}
@@ -764,7 +813,7 @@ class LocationState {
764813
{
765814
mode: this.mode,
766815
merge: true,
767-
normalize: (value) => this.hashFormat.sanitizeRecord(value),
816+
normalize: (value) => Array.isArray(value) ? value : this.hashFormat.sanitizeRecord(value),
768817
writer: (_value, settings) => this.writeURL(settings.mode),
769818
},
770819
);
@@ -879,9 +928,37 @@ function looksLikeHashText(value) {
879928
return value.startsWith("(") && value.endsWith(")")
880929
}
881930

931+
// Function: selectable
932+
// Wraps a cell into a callable function that doubles as a key-based selector.
933+
// When called with no arguments, returns the underlying cell.
934+
// When called with a key (and optional subpath), returns `cell.select(...)`.
935+
// All property access and methods are forwarded to the cell via Proxy.
936+
function selectable(cell) {
937+
const fn = (key, path) => {
938+
if (key === undefined || key === null) return cell
939+
const keyPath = Array.isArray(key) ? key : [key]
940+
if (path === undefined) return cell.select(keyPath)
941+
const extraPath = Array.isArray(path) ? path : `${path}`.split(".")
942+
return cell.select([...keyPath, ...extraPath])
943+
}
944+
return new Proxy(fn, {
945+
get(_, p) {
946+
if (p in fn) return fn[p]
947+
const v = Reflect.get(cell, p)
948+
return typeof v === "function" ? v.bind(cell) : v
949+
},
950+
set(_, p, v) { return Reflect.set(cell, p, v) },
951+
has(_, p) { return p in fn || p in cell },
952+
})
953+
}
954+
882955
// Class: Browser
883956
// Browser-backed state manager for URL, hash, query, and local storage.
884957
//
958+
// `hash` and `query` are callable selectors: `state.hash("key")` returns a
959+
// `Selected` view at that key within the hash value. Call with no args to get
960+
// the underlying cell. `state.path` is a plain cell.
961+
//
885962
// Attributes:
886963
// - `location`: LocationState - shared URL state wrapper
887964
// - `win`: Window? - browser window used for side effects
@@ -891,8 +968,8 @@ function looksLikeHashText(value) {
891968
// - `locals`: Map - registered local storage cells
892969
// - `internals`: Map - internal named cells
893970
// - `path`: Cell - path state cell
894-
// - `query`: Cell - query state cell
895-
// - `hash`: Cell - hash state cell
971+
// - `query`: Cell (callable) - query state cell
972+
// - `hash`: Cell (callable) - hash state cell
896973
class Browser {
897974
constructor(options = {}) {
898975
this.location = new LocationState(options);
@@ -908,8 +985,8 @@ class Browser {
908985
this.locals = new Map();
909986
this.internals = new Map();
910987
this.path = this.location.path;
911-
this.query = this.location.query;
912-
this.hash = this.location.hash;
988+
this.query = selectable(this.location.query);
989+
this.hash = selectable(this.location.hash);
913990

914991
this.local = this.local.bind(this);
915992
this.internal = this.internal.bind(this);

src/js/select/ui/webcomponents.js

Lines changed: 60 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
// ----------------------------------------------------------------------------
1919

2020
import { toCamelCase, toKebabCase } from "../formats.js";
21-
import { asText, isObject, Nothing } from "../utils.js";
21+
import { asText, def, isObject, Nothing } from "../utils.js";
2222
import { getUIInstance } from "./components/instance.js";
2323
import { log } from "./templates.js";
2424

@@ -33,13 +33,20 @@ const BaseHTMLElement = globalThis.HTMLElement || class {};
3333
const documentStyleSheetCache = new WeakMap();
3434
const documentStyleSubscribers = new WeakMap();
3535
const documentStyleObservers = new WeakMap();
36+
const OPTIONS = {
37+
shadow: true, // Shadow DOM by default
38+
mode: "open", // Open Shadow DOM by default
39+
};
3640

3741
function isStyleSheetNode(node) {
3842
if (!node || node.nodeType !== Node.ELEMENT_NODE) {
3943
return false;
4044
}
4145
const tagName = node.tagName?.toLowerCase();
42-
return tagName === "style" || (tagName === "link" && node.relList?.contains("stylesheet"));
46+
return (
47+
tagName === "style" ||
48+
(tagName === "link" && node.relList?.contains("stylesheet"))
49+
);
4350
}
4451

4552
function isStyleSheetMutation(mutation) {
@@ -59,6 +66,14 @@ function isStyleSheetMutation(mutation) {
5966
return false;
6067
}
6168

69+
function hashText(value) {
70+
let hash = 0;
71+
for (let i = 0; i < value.length; i++) {
72+
hash = (hash * 31 + value.charCodeAt(i)) | 0;
73+
}
74+
return hash;
75+
}
76+
6277
function getDocumentStylesSignature(doc) {
6378
if (!doc?.head?.querySelectorAll) {
6479
return "";
@@ -68,7 +83,10 @@ function getDocumentStylesSignature(doc) {
6883
for (let i = 0; i < nodes.length; i++) {
6984
const node = nodes[i];
7085
if (node.tagName?.toLowerCase() === "style") {
71-
signature.push(`style:${node.id || ""}:${node.media || ""}:${node.textContent?.length || 0}`);
86+
const text = node.textContent || "";
87+
signature.push(
88+
`style:${node.id || ""}:${node.media || ""}:${text.length}:${hashText(text)}`,
89+
);
7290
} else {
7391
signature.push(
7492
`link:${node.getAttribute("href") || ""}:${node.getAttribute("media") || ""}:${node.hasAttribute("disabled")}`,
@@ -257,11 +275,34 @@ function buildDocumentStyleSheets(doc) {
257275
return { sheets: [], fallbackNodes: [] };
258276
}
259277
const nodes = doc.head.querySelectorAll("style,link[rel~='stylesheet']");
278+
const sheets = [];
260279
const fallbackNodes = [];
280+
const HTMLStyleElementType =
281+
globalThis.HTMLStyleElement || doc.defaultView?.HTMLStyleElement;
282+
const CSSStyleSheetType =
283+
globalThis.CSSStyleSheet || doc.defaultView?.CSSStyleSheet;
261284
for (let i = 0; i < nodes.length; i++) {
262-
fallbackNodes.push(nodes[i]);
285+
const node = nodes[i];
286+
if (
287+
HTMLStyleElementType &&
288+
node instanceof HTMLStyleElementType &&
289+
typeof CSSStyleSheetType === "function"
290+
) {
291+
try {
292+
const sheet = new CSSStyleSheetType();
293+
sheet.replaceSync(node.textContent || "");
294+
sheets.push(sheet);
295+
continue;
296+
} catch (error) {
297+
log.warn("UIWebComponent: could not adopt document style, details", {
298+
node,
299+
error,
300+
});
301+
}
302+
}
303+
fallbackNodes.push(node);
263304
}
264-
return { sheets: [], fallbackNodes };
305+
return { sheets, fallbackNodes };
265306
}
266307

267308
function getDocumentStyles(doc) {
@@ -280,10 +321,10 @@ function getDocumentStyles(doc) {
280321

281322
function cloneDocumentStyles(root, options) {
282323
if (options?.documentStyles === false) {
283-
return { sheets: [], fallbackNodes: [], headChildCount: 0 };
324+
return { sheets: [], fallbackNodes: [], signature: "" };
284325
}
285326
if (!root || root === document || !document?.head?.querySelectorAll) {
286-
return { sheets: [], fallbackNodes: [], headChildCount: 0 };
327+
return { sheets: [], fallbackNodes: [], signature: "" };
287328
}
288329
return getDocumentStyles(document);
289330
}
@@ -314,8 +355,8 @@ class UIWebComponent extends BaseHTMLElement {
314355
options = {},
315356
) {
316357
super();
317-
const useShadow = options.shadow !== false;
318-
const shadowMode = options.shadowMode || "open";
358+
const useShadow = def(options.shadow, OPTIONS.shadow) !== false;
359+
const shadowMode = def(options.mode, OPTIONS.mode) || "open";
319360
this.root =
320361
useShadow && typeof this.attachShadow === "function"
321362
? this.shadowRoot || this.attachShadow({ mode: shadowMode })
@@ -498,11 +539,14 @@ class UIWebComponent extends BaseHTMLElement {
498539
}
499540
const parent = getUIInstance(parentId);
500541
if (!parent) {
501-
log.warn("UIWebComponent: ui-parent did not resolve to a mounted instance", {
502-
attribute: UI_PARENT_ATTRIBUTE,
503-
parentId,
504-
host: this,
505-
});
542+
log.warn(
543+
"UIWebComponent: ui-parent did not resolve to a mounted instance",
544+
{
545+
attribute: UI_PARENT_ATTRIBUTE,
546+
parentId,
547+
host: this,
548+
},
549+
);
506550
}
507551
return parent;
508552
}
@@ -705,7 +749,9 @@ function webcomponent(
705749
registry.define(name, WebComponent);
706750
return WebComponent;
707751
}
752+
webcomponent.options = OPTIONS;
708753

709754
export { Adopted, Disconnect, UIWebComponent, webcomponent };
755+
export default webcomponent;
710756

711757
// EOF

src/js/select/utils/func.js

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,17 +14,6 @@ import { access, bool, isObject, isThenable, isAnnotable } from "./values.js";
1414
// BASIC HELPERS
1515
//
1616
// ----------------------------------------------------------------------------
17-
18-
// Function: def
19-
// Returns the first argument that is not `undefined`.
20-
function def(...rest) {
21-
for (const v of rest) {
22-
if (v !== undefined) {
23-
return v;
24-
}
25-
}
26-
}
27-
2817
// Function: swallow
2918
// Returns the last argument in `args`.
3019
function swallow(...args) {
@@ -132,7 +121,9 @@ function collect(value, asObject = false) {
132121
resolved,
133122
]);
134123
}
135-
return Promise.all(pending).then((resolved) => collect(resolved, true));
124+
return Promise.all(pending).then((resolved) =>
125+
collect(resolved, true),
126+
);
136127
}
137128
}
138129
const res = {};
@@ -261,7 +252,6 @@ function memo(guards, functor) {
261252
export {
262253
ary,
263254
asTrue,
264-
def,
265255
extractor,
266256
idem,
267257
meta,

src/js/select/utils/values.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,16 @@
1414
//
1515
// ----------------------------------------------------------------------------
1616

17+
// Function: def
18+
// Returns the first argument that is not `undefined`.
19+
function def(...rest) {
20+
for (const v of rest) {
21+
if (v !== undefined) {
22+
return v;
23+
}
24+
}
25+
}
26+
1727
// Function: isObject
1828
// Returns true when `value` is a plain object.
1929
function isObject(value) {
@@ -510,6 +520,7 @@ export {
510520
bool,
511521
clone,
512522
composite,
523+
def,
513524
dict,
514525
empty,
515526
expand,

0 commit comments

Comments
 (0)