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:
2026-03-24 15:48:19 +00:00
parent 7434de53a6
commit e887c0d978
2 changed files with 61 additions and 3 deletions

View File

@@ -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={},
) )

View File

@@ -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);