Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/hydraline_flutter/lib/hydraline_flutter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
/// and the level-2 web runtime assets.
library;

export 'src/assets/js_custom_element.dart' show jsCustomElement;
export 'src/assets/js_dispatcher.dart' show jsDispatcher;
export 'src/assets/js_service_worker.dart' show jsServiceWorker;
export 'src/hydra_app.dart' show HydraApp, HydraScope;
export 'src/island.dart' show Island;
export 'src/island_host.dart' show IslandFactory, IslandHost;
Expand Down
33 changes: 33 additions & 0 deletions packages/hydraline_flutter/lib/src/assets/js_custom_element.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/// Custom Element `<hydraline-island>` (W-8/CE1-CE5).
///
/// Uses Declarative Shadow DOM — the existing Shadow Root from server-rendered
/// `<template shadowrootmode="open">` is reused, so there is no FOUC (CE1).
/// Budget: ≤ 2 KB.
library;

const jsCustomElement = r'''
customElements.define('hydraline-island',class extends HTMLElement{
constructor(){super();this._obs=null}
connectedCallback(){
var t=this;
if(t.getAttribute('data-hydration')==='hydrated')return;
var sr=t.shadowRoot;
if(!sr){sr=t.attachShadow({mode:'open'});t.innerHTML='';t.appendChild(
document.createElement('template').content)}var style=sr.querySelector('style');
if(!style){style=document.createElement('style');sr.insertBefore(style,sr.firstChild)}
style.textContent+=':host{display:block;contain:layout style paint}';
var size=t.getAttribute('data-size');
if(size){var wh=size.split(',');style.textContent+='.host{width:'+wh[0]+'px;height:'+wh[1]+'px}'}
var raf=null,views=[];
if(!t._obs&&window.ResizeObserver)try{t._obs=new ResizeObserver(function(es){
if(raf){cancelAnimationFrame(raf)}raf=requestAnimationFrame(function(){
var ua=navigator.userAgent||'';
if(ua.indexOf('Flutter/3.41')>-1)return;
for(var i=0;i<es.length;i++){
var e=es[i].target,w=e.offsetWidth,h=e.offsetHeight;
try{e.style.setProperty('width',w+'px','important');
e.style.setProperty('height',h+'px','important')}catch(_){}}}});
t._obs.observe(t)}catch(_){}}
disconnectedCallback(){if(this._obs){this._obs.disconnect();this._obs=null}}
});
''';
55 changes: 55 additions & 0 deletions packages/hydraline_flutter/lib/src/assets/js_dispatcher.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/// Dispatcher JS (W-9, Qwikloader-style, ≤2 KB).
///
/// Global `window.hydraline` API, all hydration directives
/// (onLoad/onIdle/onVisible/onInteraction/onMedia/manual), `data-hydration`
/// lifecycle, timeout/retry (DP1-DP6).
library;

const jsDispatcher = r'''
(function(){var A=document,B=window,io=null,idle=null,listening=!1,
engineLoading=!1,engineLoaded=!1,loadEngine=null;
function E(id){return A.querySelector('hydraline-island[id="'+id+'"]')||
A.querySelector('[data-island="htmx"][id="'+id+'"]')}
function S(el,state){el.setAttribute('data-hydration',state)}
function F(el,reason){S(el,'failed');el.dispatchEvent(new CustomEvent(
'hydraline:island-error',{bubbles:!0,detail:{id:el.id,reason:reason}}))}
function L(id,factory){var el=E(id);if(!el)return;S(el,'hydrating');
var t=setTimeout(function(){F(el,'timeout')},15000);
factory().then(function(){clearTimeout(t);S(el,'hydrated')},
function(r){clearTimeout(t);F(el,r+'')})}
function startEngine(id,factory){
if(engineLoaded){L(id,factory);return}
if(engineLoading){(loadEngine=loadEngine||[]).push([id,factory]);return}
engineLoading=!0;var s=A.createElement('script');s.src='/main.dart.js';
s.onload=function(){engineLoaded=!0;L(id,factory);
(loadEngine||[]).forEach(function(p){L(p[0],p[1])});loadEngine=null};
A.head.appendChild(s)}
B.hydraline={hydrate:function(id){
var el=E(id);if(!el||el.getAttribute('data-hydration')==='hydrated')return;
var d=el.getAttribute('data-island-level')==='htmx'?null:startEngine;if(d)d(id,function(){return Promise.resolve()})},
hydrateAll:function(){var els=A.querySelectorAll('hydraline-island[data-directive="hydrateManual"],[data-island="htmx"][data-directive="hydrateManual"]');
for(var i=0;i<els.length;i++)B.hydraline.hydrate(els[i].id)},
version:'0.1.0'};
A.addEventListener('DOMContentLoaded',function(){
var els=A.querySelectorAll('hydraline-island');
for(var i=0;i<els.length;i++){(function(el){
var d=el.getAttribute('data-directive');
if(d==='hydrateOnLoad'){B.hydraline.hydrate(el.id)}}
)(els[i])}
var onVisible=A.querySelectorAll('hydraline-island[data-directive="hydrateOnVisible"]');
if(onVisible.length&&!io){io=new IntersectionObserver(function(entries,obs){
entries.forEach(function(e){if(e.isIntersecting){B.hydraline.hydrate(e.target.id);
obs.unobserve(e.target)}})},{rootMargin:'200px'});
for(var i=0;i<onVisible.length;i++)io.observe(onVisible[i])}
var onIdle=A.querySelectorAll('hydraline-island[data-directive="hydrateOnIdle"]');
if(onIdle.length&&!idle){idle=function(){for(var i=0;i<onIdle.length;i++)
B.hydraline.hydrate(onIdle[i].id)};(B.requestIdleCallback||setTimeout)(idle)}
if(!listening){listening=!0;
A.addEventListener('click',function(ev){var el=ev.target;
while(el&&el!==A){if(el.matches&&el.matches('hydraline-island[data-directive="hydrateOnInteraction"]')){
B.hydraline.hydrate(el.id);return}el=el.parentNode}},!0)}var onMedia=A.querySelectorAll(
'hydraline-island[data-directive="hydrateOnMedia"]');for(var i=0;i<onMedia.length;i++){
(function(el){var m=el.getAttribute('data-media');
if(m&&B.matchMedia(m).matches)B.hydraline.hydrate(el.id)})(onMedia[i])}});
})();
''';
19 changes: 19 additions & 0 deletions packages/hydraline_flutter/lib/src/assets/js_service_worker.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/// Service Worker JS (W-11, ≤2 KB).
///
/// Caches `main.dart.js` and `canvaskit.*` assets (SW1).
/// WASM streaming + `<link rel="preload">` for warm visits (SW2).
library;

const jsServiceWorker = r'''
self.addEventListener('install',function(ev){
self.skipWaiting()});
self.addEventListener('activate',function(ev){
ev.waitUntil(self.clients.claim())});
self.addEventListener('fetch',function(ev){
var url=ev.request.url;
if(url.indexOf('main.dart.js')>-1||url.indexOf('canvaskit')>-1){
ev.respondWith(caches.open('hydraline-v1').then(function(cache){
return cache.match(ev.request).then(function(r){
return r||fetch(ev.request).then(function(resp){
if(resp.ok){cache.put(ev.request,resp.clone())}return resp})})}))}});
''';
1 change: 1 addition & 0 deletions packages/hydraline_flutter/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,4 @@ dev_dependencies:
flutter_lints: ^5.0.0
flutter_test:
sdk: flutter
test: ^1.25.0
56 changes: 56 additions & 0 deletions packages/hydraline_flutter/test/js_runtime_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import 'package:hydraline_flutter/hydraline_flutter.dart';
import 'package:test/test.dart';

void main() {
group('Custom Element JS (P4-01/W-8)', () {
test('is a non-empty string', () {
expect(jsCustomElement, isNotEmpty);
});

test('defines the hydraline-island custom element', () {
expect(jsCustomElement, contains('customElements.define'));
expect(jsCustomElement, contains('hydraline-island'));
});

test('uses Declarative Shadow DOM (no FOUC)', () {
expect(jsCustomElement, contains('shadowRoot'));
expect(jsCustomElement, contains('template'));
});

test('includes ResizeObserver for view constraints (CE4/CE5)', () {
expect(jsCustomElement, contains('ResizeObserver'));
});
});

group('Dispatcher JS (P4-04/W-9)', () {
test('exposes hydraline API', () {
expect(jsDispatcher, contains('hydraline'));
expect(jsDispatcher, contains('hydrate'));
});

test('manages data-hydration lifecycle states', () {
expect(jsDispatcher, contains('data-hydration'));
expect(jsDispatcher, contains('hydrated'));
expect(jsDispatcher, contains('failed'));
});

test('dispatches hydraline:island-error on failure', () {
expect(jsDispatcher, contains('island-error'));
});

test('uses IntersectionObserver for hydrateOnVisible', () {
expect(jsDispatcher, contains('IntersectionObserver'));
});
});

group('Service Worker JS (P4-06/W-11)', () {
test('handles fetch and caches WASM assets', () {
expect(jsServiceWorker, contains("addEventListener('fetch'"));
expect(jsServiceWorker, contains('canvaskit'));
});

test('uses caches.open for the hydraline cache', () {
expect(jsServiceWorker, contains('caches.open'));
});
});
}
Empty file.
46 changes: 46 additions & 0 deletions packages/hydraline_flutter/web/hydraline-dispatcher.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
(function(){var A=document,B=window,io=null,idle=null,listening=!1,
engineLoading=!1,engineLoaded=!1,loadEngine=null;
function E(id){return A.querySelector('hydraline-island[id="'+id+'"]')||
A.querySelector('[data-island="htmx"][id="'+id+'"]')}
function S(el,state){el.setAttribute('data-hydration',state)}
function F(el,reason){S(el,'failed');el.dispatchEvent(new CustomEvent(
'hydraline:island-error',{bubbles:!0,detail:{id:el.id,reason:reason}}))}
function L(id,factory){var el=E(id);if(!el)return;S(el,'hydrating');
var t=setTimeout(function(){F(el,'timeout')},15000);
factory().then(function(){clearTimeout(t);S(el,'hydrated')},
function(r){clearTimeout(t);F(el,r+'')})}
function startEngine(id,factory){
if(engineLoaded){L(id,factory);return}
if(engineLoading){(loadEngine=loadEngine||[]).push([id,factory]);return}
engineLoading=!0;var s=A.createElement('script');s.src='/main.dart.js';
s.onload=function(){engineLoaded=!0;L(id,factory);
(loadEngine||[]).forEach(function(p){L(p[0],p[1])});loadEngine=null};
A.head.appendChild(s)}
B.hydraline={hydrate:function(id){
var el=E(id);if(!el||el.getAttribute('data-hydration')==='hydrated')return;
var d=el.getAttribute('data-island-level')==='htmx'?null:startEngine;if(d)d(id,function(){return Promise.resolve()})},
hydrateAll:function(){var els=A.querySelectorAll('hydraline-island[data-directive="hydrateManual"],[data-island="htmx"][data-directive="hydrateManual"]');
for(var i=0;i<els.length;i++)B.hydraline.hydrate(els[i].id)},
version:'0.1.0'};
A.addEventListener('DOMContentLoaded',function(){
var els=A.querySelectorAll('hydraline-island');
for(var i=0;i<els.length;i++){(function(el){
var d=el.getAttribute('data-directive');
if(d==='hydrateOnLoad'){B.hydraline.hydrate(el.id)}}
)(els[i])}
var onVisible=A.querySelectorAll('hydraline-island[data-directive="hydrateOnVisible"]');
if(onVisible.length&&!io){io=new IntersectionObserver(function(entries,obs){
entries.forEach(function(e){if(e.isIntersecting){B.hydraline.hydrate(e.target.id);
obs.unobserve(e.target)}})},{rootMargin:'200px'});
for(var i=0;i<onVisible.length;i++)io.observe(onVisible[i])}
var onIdle=A.querySelectorAll('hydraline-island[data-directive="hydrateOnIdle"]');
if(onIdle.length&&!idle){idle=function(){for(var i=0;i<onIdle.length;i++)
B.hydraline.hydrate(onIdle[i].id)};(B.requestIdleCallback||setTimeout)(idle)}
if(!listening){listening=!0;
A.addEventListener('click',function(ev){var el=ev.target;
while(el&&el!==A){if(el.matches&&el.matches('hydraline-island[data-directive="hydrateOnInteraction"]')){
B.hydraline.hydrate(el.id);return}el=el.parentNode}},!0)}var onMedia=A.querySelectorAll(
'hydraline-island[data-directive="hydrateOnMedia"]');for(var i=0;i<onMedia.length;i++){
(function(el){var m=el.getAttribute('data-media');
if(m&&B.matchMedia(m).matches)B.hydraline.hydrate(el.id)})(onMedia[i])}});
})();
24 changes: 24 additions & 0 deletions packages/hydraline_flutter/web/hydraline-island.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
customElements.define('hydraline-island',class extends HTMLElement{
constructor(){super();this._obs=null}
connectedCallback(){
var t=this;
if(t.getAttribute('data-hydration')==='hydrated')return;
var sr=t.shadowRoot;
if(!sr){sr=t.attachShadow({mode:'open'});t.innerHTML='';t.appendChild(
document.createElement('template').content)}var style=sr.querySelector('style');
if(!style){style=document.createElement('style');sr.insertBefore(style,sr.firstChild)}
style.textContent+=':host{display:block;contain:layout style paint}';
var size=t.getAttribute('data-size');
if(size){var wh=size.split(',');style.textContent+='.host{width:'+wh[0]+'px;height:'+wh[1]+'px}'}
var raf=null,views=[];
if(!t._obs&&window.ResizeObserver)try{t._obs=new ResizeObserver(function(es){
if(raf){cancelAnimationFrame(raf)}raf=requestAnimationFrame(function(){
var ua=navigator.userAgent||'';
if(ua.indexOf('Flutter/3.41')>-1)return;
for(var i=0;i<es.length;i++){
var e=es[i].target,w=e.offsetWidth,h=e.offsetHeight;
try{e.style.setProperty('width',w+'px','important');
e.style.setProperty('height',h+'px','important')}catch(_){}}}});
t._obs.observe(t)}catch(_){}}
disconnectedCallback(){if(this._obs){this._obs.disconnect();this._obs=null}}
});
11 changes: 11 additions & 0 deletions packages/hydraline_flutter/web/service-worker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
self.addEventListener('install',function(ev){
self.skipWaiting()});
self.addEventListener('activate',function(ev){
ev.waitUntil(self.clients.claim())});
self.addEventListener('fetch',function(ev){
var url=ev.request.url;
if(url.indexOf('main.dart.js')>-1||url.indexOf('canvaskit')>-1){
ev.respondWith(caches.open('hydraline-v1').then(function(cache){
return cache.match(ev.request).then(function(r){
return r||fetch(ev.request).then(function(resp){
if(resp.ok){cache.put(ev.request,resp.clone())}return resp})})}))}});
Loading