Fix defisland→Component bug in jinja_bridge; add island reactivity test
jinja_bridge.py was creating Component objects for both defcomp AND defisland forms. Islands need Island objects so the serializer emits defisland (not defcomp) in the client component bundle. Without this, client-side islands don't get data-sx-island attributes, hydration fails, and all reactive signals (colour cycling, stepper) stop working. Add Playwright test: islands hydrate, stepper buttons update count, reactive colour cycling works on click. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -396,6 +396,40 @@ def load_handler_dir(directory: str, service_name: str) -> None:
|
|||||||
_load(directory, service_name)
|
_load(directory, service_name)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_defcomp_params(param_form: list) -> tuple[list[str], bool]:
|
||||||
|
"""Extract keyword param names and has_children from a defcomp param list.
|
||||||
|
|
||||||
|
Handles: (&key p1 p2 &rest children), (&key (p1 :as type) &rest children),
|
||||||
|
(p1 p2), () etc.
|
||||||
|
|
||||||
|
Returns (param_names, has_children).
|
||||||
|
"""
|
||||||
|
if not isinstance(param_form, list):
|
||||||
|
return [], False
|
||||||
|
|
||||||
|
params: list[str] = []
|
||||||
|
has_children = False
|
||||||
|
in_key = False
|
||||||
|
i = 0
|
||||||
|
while i < len(param_form):
|
||||||
|
item = param_form[i]
|
||||||
|
if isinstance(item, Symbol):
|
||||||
|
sname = item.name
|
||||||
|
if sname == "&key":
|
||||||
|
in_key = True
|
||||||
|
elif sname == "&rest":
|
||||||
|
has_children = True
|
||||||
|
i += 1 # skip the rest-param name (e.g. 'children')
|
||||||
|
else:
|
||||||
|
params.append(sname)
|
||||||
|
elif isinstance(item, list):
|
||||||
|
# Typed param: (name :as type)
|
||||||
|
if item and isinstance(item[0], Symbol):
|
||||||
|
params.append(item[0].name)
|
||||||
|
i += 1
|
||||||
|
return params, has_children
|
||||||
|
|
||||||
|
|
||||||
def register_components(sx_source: str, *, _defer_postprocess: bool = False) -> None:
|
def register_components(sx_source: str, *, _defer_postprocess: bool = False) -> None:
|
||||||
"""Parse and evaluate s-expression component definitions into the
|
"""Parse and evaluate s-expression component definitions into the
|
||||||
shared environment.
|
shared environment.
|
||||||
@@ -410,7 +444,7 @@ def register_components(sx_source: str, *, _defer_postprocess: bool = False) ->
|
|||||||
existing = set(_COMPONENT_ENV.keys())
|
existing = set(_COMPONENT_ENV.keys())
|
||||||
|
|
||||||
# Evaluate definitions — OCaml kernel handles everything.
|
# Evaluate definitions — OCaml kernel handles everything.
|
||||||
# Python-side component registry is populated minimally for CSS/deps.
|
# Python-side component registry is populated with parsed params for CSS/deps.
|
||||||
exprs = parse_all(sx_source)
|
exprs = parse_all(sx_source)
|
||||||
for expr in exprs:
|
for expr in exprs:
|
||||||
if (isinstance(expr, list) and expr and isinstance(expr[0], Symbol)
|
if (isinstance(expr, list) and expr and isinstance(expr[0], Symbol)
|
||||||
@@ -420,9 +454,11 @@ def register_components(sx_source: str, *, _defer_postprocess: bool = False) ->
|
|||||||
name_sym = expr[1] if len(expr) > 1 else None
|
name_sym = expr[1] if len(expr) > 1 else None
|
||||||
name = name_sym.name if hasattr(name_sym, 'name') else str(name_sym) if name_sym else None
|
name = name_sym.name if hasattr(name_sym, 'name') else str(name_sym) if name_sym else None
|
||||||
if name and expr[0].name in ("defcomp", "defisland"):
|
if name and expr[0].name in ("defcomp", "defisland"):
|
||||||
_COMPONENT_ENV[name] = Component(
|
params, has_children = _parse_defcomp_params(expr[2] if len(expr) > 3 else [])
|
||||||
|
cls = Island if expr[0].name == "defisland" else Component
|
||||||
|
_COMPONENT_ENV[name] = cls(
|
||||||
name=name.lstrip("~"),
|
name=name.lstrip("~"),
|
||||||
params=[], has_children=False,
|
params=params, has_children=has_children,
|
||||||
body=expr[-1], closure={},
|
body=expr[-1], closure={},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -136,6 +136,28 @@ test.describe('Isomorphic SSR', () => {
|
|||||||
await context.close();
|
await context.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('islands hydrate and reactive signals work', async ({ page }) => {
|
||||||
|
await page.goto(BASE_URL + '/sx/', { waitUntil: 'networkidle' });
|
||||||
|
await page.waitForSelector('[data-sx-island="layouts/header"]', { timeout: 15000 });
|
||||||
|
await page.waitForSelector('[data-sx-island="home/stepper"]', { timeout: 5000 });
|
||||||
|
|
||||||
|
// Stepper buttons change the count
|
||||||
|
const stepper = page.locator('[data-sx-island="home/stepper"]');
|
||||||
|
const textBefore = await stepper.textContent();
|
||||||
|
await stepper.locator('button').last().click();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
const textAfter = await stepper.textContent();
|
||||||
|
expect(textAfter).not.toBe(textBefore);
|
||||||
|
|
||||||
|
// Reactive colour cycling on "reactive" word
|
||||||
|
const reactive = page.locator('[data-sx-island="layouts/header"]').getByText('reactive');
|
||||||
|
const colourBefore = await reactive.evaluate(el => el.style.color);
|
||||||
|
await reactive.click();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
const colourAfter = await reactive.evaluate(el => el.style.color);
|
||||||
|
expect(colourAfter).not.toBe(colourBefore);
|
||||||
|
});
|
||||||
|
|
||||||
test('navigation links have valid URLs (no [object Object])', async ({ page }) => {
|
test('navigation links have valid URLs (no [object Object])', async ({ page }) => {
|
||||||
await page.goto(BASE_URL + '/sx/', { waitUntil: 'networkidle' });
|
await page.goto(BASE_URL + '/sx/', { waitUntil: 'networkidle' });
|
||||||
await page.waitForTimeout(1000);
|
await page.waitForTimeout(1000);
|
||||||
|
|||||||
Reference in New Issue
Block a user